@@ -140,17 +165,18 @@ function MultiCredentialsLookup(props) {
key: 'name',
},
]}
- multiple={isMultiple}
+ multiple={isVault}
header={i18n._(t`Credentials`)}
name="credentials"
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={item => {
- if (isMultiple) {
- return dispatch({ type: 'SELECT_ITEM', item });
- }
- const selectedItems = state.selectedItems.filter(
- i => i.kind !== item.kind
+ const hasSameVaultID = val =>
+ val?.inputs?.vault_id !== undefined &&
+ val?.inputs?.vault_id === item?.inputs?.vault_id;
+ const hasSameKind = val => val.kind === item.kind;
+ const selectedItems = state.selectedItems.filter(i =>
+ isVault ? !hasSameVaultID(i) : !hasSameKind(i)
);
selectedItems.push(item);
return dispatch({
diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx
index fa73edad3a..fa8eb5a15f 100644
--- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx
+++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx
@@ -12,7 +12,8 @@ describe('
', () => {
const credentials = [
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
{ id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' },
- { name: 'Gatsby', id: 21, kind: 'vault' },
+ { name: 'Gatsby', id: 21, kind: 'vault', inputs: { vault_id: '1' } },
+ { name: 'Gatsby 2', id: 23, kind: 'vault' },
{ name: 'Gatsby', id: 8, kind: 'Machine' },
];
@@ -80,14 +81,15 @@ describe('
', () => {
);
});
const chip = wrapper.find('CredentialChip');
- expect(chip).toHaveLength(4);
+ expect(chip).toHaveLength(5);
const button = chip.at(1).find('ChipButton');
await act(async () => {
button.invoke('onClick')();
});
expect(onChange).toBeCalledWith([
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
- { id: 21, kind: 'vault', name: 'Gatsby' },
+ { id: 21, inputs: { vault_id: '1' }, kind: 'vault', name: 'Gatsby' },
+ { id: 23, kind: 'vault', name: 'Gatsby 2' },
{ id: 8, kind: 'Machine', name: 'Gatsby' },
]);
});
@@ -161,12 +163,13 @@ describe('
', () => {
expect(onChange).toBeCalledWith([
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
{ id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' },
- { id: 21, kind: 'vault', name: 'Gatsby' },
+ { id: 21, inputs: { vault_id: '1' }, kind: 'vault', name: 'Gatsby' },
+ { id: 23, kind: 'vault', name: 'Gatsby 2' },
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
]);
});
- test('should allow multiple vault credentials', async () => {
+ test('should allow multiple vault credentials with no vault id', async () => {
const onChange = jest.fn();
await act(async () => {
wrapper = mountWithContexts(
@@ -193,7 +196,7 @@ describe('
', () => {
act(() => {
optionsList.invoke('selectItem')({
id: 5,
- kind: 'Machine',
+ kind: 'vault',
name: 'Cred 5',
url: 'www.google.com',
});
@@ -205,9 +208,115 @@ describe('
', () => {
expect(onChange).toBeCalledWith([
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
{ id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' },
- { id: 21, kind: 'vault', name: 'Gatsby' },
+ { id: 21, kind: 'vault', name: 'Gatsby', inputs: { vault_id: '1' } },
+ { id: 23, kind: 'vault', name: 'Gatsby 2' },
{ id: 8, kind: 'Machine', name: 'Gatsby' },
- { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
+ { id: 5, kind: 'vault', name: 'Cred 5', url: 'www.google.com' },
+ ]);
+ });
+
+ test('should allow multiple vault credentials with different vault ids', async () => {
+ const onChange = jest.fn();
+ await act(async () => {
+ wrapper = mountWithContexts(
+
{}}
+ />
+ );
+ });
+ const searchButton = await waitForElement(wrapper, 'SearchButton');
+ await act(async () => {
+ searchButton.invoke('onClick')();
+ });
+ wrapper.update();
+ const typeSelect = wrapper.find('AnsibleSelect');
+ act(() => {
+ typeSelect.invoke('onChange')({}, 500);
+ });
+ wrapper.update();
+ const optionsList = wrapper.find('OptionsList');
+ expect(optionsList.prop('multiple')).toEqual(true);
+ act(() => {
+ optionsList.invoke('selectItem')({
+ id: 5,
+ kind: 'vault',
+ name: 'Cred 5',
+ url: 'www.google.com',
+ inputs: { vault_id: '2' },
+ });
+ });
+ wrapper.update();
+ act(() => {
+ wrapper.find('Button[variant="primary"]').invoke('onClick')();
+ });
+ expect(onChange).toBeCalledWith([
+ { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
+ { id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' },
+ { id: 21, kind: 'vault', name: 'Gatsby', inputs: { vault_id: '1' } },
+ { id: 23, kind: 'vault', name: 'Gatsby 2' },
+ { id: 8, kind: 'Machine', name: 'Gatsby' },
+ {
+ id: 5,
+ kind: 'vault',
+ name: 'Cred 5',
+ url: 'www.google.com',
+ inputs: { vault_id: '2' },
+ },
+ ]);
+ });
+
+ test('should not select multiple vault credentials with same vault id', async () => {
+ const onChange = jest.fn();
+ await act(async () => {
+ wrapper = mountWithContexts(
+ {}}
+ />
+ );
+ });
+ const searchButton = await waitForElement(wrapper, 'SearchButton');
+ await act(async () => {
+ searchButton.invoke('onClick')();
+ });
+ wrapper.update();
+ const typeSelect = wrapper.find('AnsibleSelect');
+ act(() => {
+ typeSelect.invoke('onChange')({}, 500);
+ });
+ wrapper.update();
+ const optionsList = wrapper.find('OptionsList');
+ expect(optionsList.prop('multiple')).toEqual(true);
+ act(() => {
+ optionsList.invoke('selectItem')({
+ id: 24,
+ kind: 'vault',
+ name: 'Cred 5',
+ url: 'www.google.com',
+ inputs: { vault_id: '1' },
+ });
+ });
+ wrapper.update();
+ act(() => {
+ wrapper.find('Button[variant="primary"]').invoke('onClick')();
+ });
+ expect(onChange).toBeCalledWith([
+ { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
+ { id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' },
+ { id: 23, kind: 'vault', name: 'Gatsby 2' },
+ { id: 8, kind: 'Machine', name: 'Gatsby' },
+ {
+ id: 24,
+ kind: 'vault',
+ name: 'Cred 5',
+ url: 'www.google.com',
+ inputs: { vault_id: '1' },
+ },
]);
});
});
diff --git a/awx/ui_next/src/components/MultiButtonToggle/MultiButtonToggle.jsx b/awx/ui_next/src/components/MultiButtonToggle/MultiButtonToggle.jsx
index 9c72cfbab3..b76c4c6a61 100644
--- a/awx/ui_next/src/components/MultiButtonToggle/MultiButtonToggle.jsx
+++ b/awx/ui_next/src/components/MultiButtonToggle/MultiButtonToggle.jsx
@@ -21,6 +21,7 @@ function MultiButtonToggle({ buttons, value, onChange }) {
{buttons &&
buttons.map(([buttonValue, buttonLabel]) => (
setValue(buttonValue)}
variant={buttonValue === value ? 'primary' : 'secondary'}
diff --git a/awx/ui_next/src/components/Schedule/Schedule.jsx b/awx/ui_next/src/components/Schedule/Schedule.jsx
new file mode 100644
index 0000000000..852f811b40
--- /dev/null
+++ b/awx/ui_next/src/components/Schedule/Schedule.jsx
@@ -0,0 +1,140 @@
+import React, { useEffect, useState } from 'react';
+import { t } from '@lingui/macro';
+import { withI18n } from '@lingui/react';
+
+import {
+ Switch,
+ Route,
+ Link,
+ Redirect,
+ useLocation,
+ useParams,
+} from 'react-router-dom';
+import { CardActions } from '@patternfly/react-core';
+import { CaretLeftIcon } from '@patternfly/react-icons';
+import CardCloseButton from '@components/CardCloseButton';
+import RoutedTabs from '@components/RoutedTabs';
+import ContentError from '@components/ContentError';
+import ContentLoading from '@components/ContentLoading';
+import { TabbedCardHeader } from '@components/Card';
+import { ScheduleDetail } from '@components/Schedule';
+import { SchedulesAPI } from '@api';
+
+function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) {
+ const [schedule, setSchedule] = useState(null);
+ const [contentLoading, setContentLoading] = useState(true);
+ const [contentError, setContentError] = useState(null);
+ const { scheduleId } = useParams();
+ const location = useLocation();
+ const { pathname } = location;
+ const pathRoot = pathname.substr(0, pathname.indexOf('schedules'));
+
+ useEffect(() => {
+ const loadData = async () => {
+ try {
+ const { data } = await SchedulesAPI.readDetail(scheduleId);
+ setSchedule(data);
+ setBreadcrumb(unifiedJobTemplate, data);
+ } catch (err) {
+ setContentError(err);
+ } finally {
+ setContentLoading(false);
+ }
+ };
+
+ loadData();
+ }, [location.pathname, scheduleId, unifiedJobTemplate, setBreadcrumb]);
+
+ const tabsArray = [
+ {
+ name: (
+ <>
+
+ {i18n._(t`Back to Schedules`)}
+ >
+ ),
+ link: `${pathRoot}schedules`,
+ id: 99,
+ },
+ {
+ name: i18n._(t`Details`),
+ link: `${pathRoot}schedules/${schedule && schedule.id}/details`,
+ id: 0,
+ },
+ ];
+
+ if (contentLoading) {
+ return ;
+ }
+
+ if (
+ schedule.summary_fields.unified_job_template.id !==
+ parseInt(unifiedJobTemplate.id, 10)
+ ) {
+ return (
+
+ {schedule && (
+ {i18n._(t`View Schedules`)}
+ )}
+
+ );
+ }
+
+ if (contentError) {
+ return ;
+ }
+
+ let cardHeader = null;
+ if (
+ location.pathname.includes('schedules/') &&
+ !location.pathname.endsWith('edit')
+ ) {
+ cardHeader = (
+
+
+
+
+
+
+ );
+ }
+ return (
+ <>
+ {cardHeader}
+
+
+ {schedule && [
+ {
+ return ;
+ }}
+ />,
+ ]}
+ {
+ return (
+
+ {unifiedJobTemplate && (
+
+ {i18n._(t`View Details`)}
+
+ )}
+
+ );
+ }}
+ />
+
+ >
+ );
+}
+
+export { Schedule as _Schedule };
+export default withI18n()(Schedule);
diff --git a/awx/ui_next/src/components/Schedule/Schedule.test.jsx b/awx/ui_next/src/components/Schedule/Schedule.test.jsx
new file mode 100644
index 0000000000..3ed3c5b025
--- /dev/null
+++ b/awx/ui_next/src/components/Schedule/Schedule.test.jsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import { SchedulesAPI } from '@api';
+import { Route } from 'react-router-dom';
+import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import Schedule from './Schedule';
+
+jest.mock('@api/models/Schedules');
+
+SchedulesAPI.readDetail.mockResolvedValue({
+ data: {
+ url: '/api/v2/schedules/1',
+ rrule:
+ 'DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1',
+ id: 1,
+ summary_fields: {
+ unified_job_template: {
+ id: 1,
+ name: 'Mock JT',
+ description: '',
+ unified_job_type: 'job',
+ },
+ user_capabilities: {
+ edit: true,
+ delete: true,
+ },
+ created_by: {
+ id: 1,
+ username: 'admin',
+ first_name: '',
+ last_name: '',
+ },
+ modified_by: {
+ id: 1,
+ username: 'admin',
+ first_name: '',
+ last_name: '',
+ },
+ },
+ created: '2020-03-03T20:38:54.210306Z',
+ modified: '2020-03-03T20:38:54.210336Z',
+ name: 'Mock JT Schedule',
+ next_run: '2020-02-20T05:00:00Z',
+ },
+});
+
+SchedulesAPI.createPreview.mockResolvedValue({
+ data: {
+ local: [],
+ utc: [],
+ },
+});
+
+SchedulesAPI.readCredentials.mockResolvedValue({
+ data: {
+ count: 0,
+ results: [],
+ },
+});
+
+describe('', () => {
+ let wrapper;
+ let history;
+ const unifiedJobTemplate = { id: 1, name: 'Mock JT' };
+ beforeAll(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/templates/job_template/1/schedules/1/details'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+ (
+ {}}
+ unifiedJobTemplate={unifiedJobTemplate}
+ />
+ )}
+ />,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ match: {
+ params: { id: 1 },
+ },
+ },
+ },
+ },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+ afterAll(() => {
+ wrapper.unmount();
+ });
+ test('renders successfully', async () => {
+ expect(wrapper.length).toBe(1);
+ });
+ test('expect all tabs to exist, including Back to Schedules', async () => {
+ expect(
+ wrapper.find('button[link="/templates/job_template/1/schedules"]').length
+ ).toBe(1);
+ expect(wrapper.find('button[aria-label="Details"]').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx
new file mode 100644
index 0000000000..c6ae0b97c4
--- /dev/null
+++ b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx
@@ -0,0 +1,202 @@
+import React, { useCallback, useEffect } from 'react';
+import { Link } from 'react-router-dom';
+import { rrulestr } from 'rrule';
+import styled from 'styled-components';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { Schedule } from '@types';
+import { Chip, ChipGroup, Title } from '@patternfly/react-core';
+import { CardBody } from '@components/Card';
+import ContentError from '@components/ContentError';
+import ContentLoading from '@components/ContentLoading';
+import CredentialChip from '@components/CredentialChip';
+import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
+import { ScheduleOccurrences, ScheduleToggle } from '@components/Schedule';
+import { formatDateString } from '@util/dates';
+import useRequest from '@util/useRequest';
+import { SchedulesAPI } from '@api';
+
+const PromptTitle = styled(Title)`
+ --pf-c-title--m-md--FontWeight: 700;
+`;
+
+function ScheduleDetail({ schedule, i18n }) {
+ const {
+ id,
+ created,
+ description,
+ diff_mode,
+ dtend,
+ dtstart,
+ job_tags,
+ job_type,
+ inventory,
+ limit,
+ modified,
+ name,
+ next_run,
+ rrule,
+ scm_branch,
+ skip_tags,
+ summary_fields,
+ timezone,
+ } = schedule;
+
+ const {
+ result: [credentials, preview],
+ isLoading,
+ error,
+ request: fetchCredentialsAndPreview,
+ } = useRequest(
+ useCallback(async () => {
+ const [{ data }, { data: schedulePreview }] = await Promise.all([
+ SchedulesAPI.readCredentials(id),
+ SchedulesAPI.createPreview({
+ rrule,
+ }),
+ ]);
+ return [data.results, schedulePreview];
+ }, [id, rrule]),
+ []
+ );
+
+ useEffect(() => {
+ fetchCredentialsAndPreview();
+ }, [fetchCredentialsAndPreview]);
+
+ const rule = rrulestr(rrule);
+ const repeatFrequency =
+ rule.options.freq === 3 && dtstart === dtend
+ ? i18n._(t`None (Run Once)`)
+ : rule.toText().replace(/^\w/, c => c.toUpperCase());
+ const showPromptedFields =
+ (credentials && credentials.length > 0) ||
+ job_type ||
+ (inventory && summary_fields.inventory) ||
+ scm_branch ||
+ limit ||
+ typeof diff_mode === 'boolean' ||
+ (job_tags && job_tags.length > 0) ||
+ (skip_tags && skip_tags.length > 0);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {showPromptedFields && (
+ <>
+
+ {i18n._(t`Prompted Fields`)}
+
+
+ {inventory && summary_fields.inventory && (
+
+ {summary_fields.inventory.name}
+
+ }
+ />
+ )}
+
+
+ {typeof diff_mode === 'boolean' && (
+
+ )}
+ {credentials && credentials.length > 0 && (
+
+ {credentials.map(c => (
+
+ ))}
+
+ }
+ />
+ )}
+ {job_tags && job_tags.length > 0 && (
+
+ {job_tags.split(',').map(jobTag => (
+
+ {jobTag}
+
+ ))}
+
+ }
+ />
+ )}
+ {skip_tags && skip_tags.length > 0 && (
+
+ {skip_tags.split(',').map(skipTag => (
+
+ {skipTag}
+
+ ))}
+
+ }
+ />
+ )}
+ >
+ )}
+
+
+ );
+}
+
+ScheduleDetail.propTypes = {
+ schedule: Schedule.isRequired,
+};
+
+export default withI18n()(ScheduleDetail);
diff --git a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.test.jsx
new file mode 100644
index 0000000000..d67ff21fc2
--- /dev/null
+++ b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.test.jsx
@@ -0,0 +1,261 @@
+import React from 'react';
+import { SchedulesAPI } from '@api';
+import { Route } from 'react-router-dom';
+import { createMemoryHistory } from 'history';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
+import ScheduleDetail from './ScheduleDetail';
+
+jest.mock('@api/models/Schedules');
+
+const schedule = {
+ url: '/api/v2/schedules/1',
+ rrule:
+ 'DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1',
+ id: 1,
+ summary_fields: {
+ unified_job_template: {
+ id: 1,
+ name: 'Mock JT',
+ description: '',
+ unified_job_type: 'job',
+ },
+ user_capabilities: {
+ edit: true,
+ delete: true,
+ },
+ created_by: {
+ id: 1,
+ username: 'admin',
+ first_name: '',
+ last_name: '',
+ },
+ modified_by: {
+ id: 1,
+ username: 'admin',
+ first_name: '',
+ last_name: '',
+ },
+ inventory: {
+ id: 1,
+ name: 'Test Inventory',
+ },
+ },
+ created: '2020-03-03T20:38:54.210306Z',
+ modified: '2020-03-03T20:38:54.210336Z',
+ name: 'Mock JT Schedule',
+ enabled: false,
+ description: 'A good schedule',
+ timezone: 'America/New_York',
+ dtstart: '2020-03-16T04:00:00Z',
+ dtend: '2020-07-06T04:00:00Z',
+ next_run: '2020-03-16T04:00:00Z',
+};
+
+SchedulesAPI.createPreview.mockResolvedValue({
+ data: {
+ local: [],
+ utc: [],
+ },
+});
+
+describe('', () => {
+ let wrapper;
+ const history = createMemoryHistory({
+ initialEntries: ['/templates/job_template/1/schedules/1/details'],
+ });
+ afterEach(() => {
+ wrapper.unmount();
+ });
+ test('details should render with the proper values without prompts', async () => {
+ SchedulesAPI.readCredentials.mockResolvedValueOnce({
+ data: {
+ count: 0,
+ results: [],
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+ }
+ />,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ match: { params: { id: 1 } },
+ },
+ },
+ },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(
+ wrapper
+ .find('Detail[label="Name"]')
+ .find('dd')
+ .text()
+ ).toBe('Mock JT Schedule');
+ expect(
+ wrapper
+ .find('Detail[label="Description"]')
+ .find('dd')
+ .text()
+ ).toBe('A good schedule');
+ expect(wrapper.find('Detail[label="First Run"]').length).toBe(1);
+ expect(wrapper.find('Detail[label="Next Run"]').length).toBe(1);
+ expect(wrapper.find('Detail[label="Last Run"]').length).toBe(1);
+ expect(
+ wrapper
+ .find('Detail[label="Local Time Zone"]')
+ .find('dd')
+ .text()
+ ).toBe('America/New_York');
+ expect(wrapper.find('Detail[label="Repeat Frequency"]').length).toBe(1);
+ expect(wrapper.find('Detail[label="Created"]').length).toBe(1);
+ expect(wrapper.find('Detail[label="Last Modified"]').length).toBe(1);
+ expect(wrapper.find('Title[children="Prompted Fields"]').length).toBe(0);
+ expect(wrapper.find('Detail[label="Job Type"]').length).toBe(0);
+ expect(wrapper.find('Detail[label="Inventory"]').length).toBe(0);
+ expect(wrapper.find('Detail[label="SCM Branch"]').length).toBe(0);
+ expect(wrapper.find('Detail[label="Limit"]').length).toBe(0);
+ expect(wrapper.find('Detail[label="Show Changes"]').length).toBe(0);
+ expect(wrapper.find('Detail[label="Credentials"]').length).toBe(0);
+ expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(0);
+ expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(0);
+ });
+ test('details should render with the proper values with prompts', async () => {
+ SchedulesAPI.readCredentials.mockResolvedValueOnce({
+ data: {
+ count: 2,
+ results: [
+ {
+ id: 1,
+ name: 'Cred 1',
+ },
+ {
+ id: 2,
+ name: 'Cred 2',
+ },
+ ],
+ },
+ });
+ const scheduleWithPrompts = {
+ ...schedule,
+ job_type: 'run',
+ inventory: 1,
+ job_tags: 'tag1',
+ skip_tags: 'tag2',
+ scm_branch: 'foo/branch',
+ limit: 'localhost',
+ diff_mode: true,
+ verbosity: 1,
+ };
+ await act(async () => {
+ wrapper = mountWithContexts(
+ }
+ />,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ match: { params: { id: 1 } },
+ },
+ },
+ },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(
+ wrapper
+ .find('Detail[label="Name"]')
+ .find('dd')
+ .text()
+ ).toBe('Mock JT Schedule');
+ expect(
+ wrapper
+ .find('Detail[label="Description"]')
+ .find('dd')
+ .text()
+ ).toBe('A good schedule');
+ expect(wrapper.find('Detail[label="First Run"]').length).toBe(1);
+ expect(wrapper.find('Detail[label="Next Run"]').length).toBe(1);
+ expect(wrapper.find('Detail[label="Last Run"]').length).toBe(1);
+ expect(
+ wrapper
+ .find('Detail[label="Local Time Zone"]')
+ .find('dd')
+ .text()
+ ).toBe('America/New_York');
+ expect(wrapper.find('Detail[label="Repeat Frequency"]').length).toBe(1);
+ expect(wrapper.find('Detail[label="Created"]').length).toBe(1);
+ expect(wrapper.find('Detail[label="Last Modified"]').length).toBe(1);
+ expect(wrapper.find('Title[children="Prompted Fields"]').length).toBe(1);
+ expect(
+ wrapper
+ .find('Detail[label="Job Type"]')
+ .find('dd')
+ .text()
+ ).toBe('run');
+ expect(wrapper.find('Detail[label="Inventory"]').length).toBe(1);
+ expect(
+ wrapper
+ .find('Detail[label="SCM Branch"]')
+ .find('dd')
+ .text()
+ ).toBe('foo/branch');
+ expect(
+ wrapper
+ .find('Detail[label="Limit"]')
+ .find('dd')
+ .text()
+ ).toBe('localhost');
+ expect(wrapper.find('Detail[label="Show Changes"]').length).toBe(1);
+ expect(wrapper.find('Detail[label="Credentials"]').length).toBe(1);
+ expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(1);
+ expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(1);
+ });
+ test('error shown when error encountered fetching credentials', async () => {
+ SchedulesAPI.readCredentials.mockRejectedValueOnce(
+ new Error({
+ response: {
+ config: {
+ method: 'get',
+ url: '/api/v2/job_templates/1/schedules/1/credentials',
+ },
+ data: 'An error occurred',
+ status: 500,
+ },
+ })
+ );
+ await act(async () => {
+ wrapper = mountWithContexts(
+ }
+ />,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ match: { params: { id: 1 } },
+ },
+ },
+ },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentError', el => el.length === 1);
+ });
+});
diff --git a/awx/ui_next/src/components/Schedule/ScheduleDetail/index.js b/awx/ui_next/src/components/Schedule/ScheduleDetail/index.js
new file mode 100644
index 0000000000..dc7a5b7477
--- /dev/null
+++ b/awx/ui_next/src/components/Schedule/ScheduleDetail/index.js
@@ -0,0 +1 @@
+export { default } from './ScheduleDetail';
diff --git a/awx/ui_next/src/components/ScheduleList/ScheduleList.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx
similarity index 78%
rename from awx/ui_next/src/components/ScheduleList/ScheduleList.jsx
rename to awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx
index e24e46e0b6..999f28a6b9 100644
--- a/awx/ui_next/src/components/ScheduleList/ScheduleList.jsx
+++ b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
+import { bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { SchedulesAPI } from '@api';
@@ -7,6 +8,7 @@ import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail';
import DataListToolbar from '@components/DataListToolbar';
import PaginatedDataList, {
+ ToolbarAddButton,
ToolbarDeleteButton,
} from '@components/PaginatedDataList';
import useRequest, { useDeleteItems } from '@util/useRequest';
@@ -19,28 +21,40 @@ const QS_CONFIG = getQSConfig('schedule', {
order_by: 'unified_job_template__polymorphic_ctype__model',
});
-function ScheduleList({ i18n, loadSchedules }) {
+function ScheduleList({
+ i18n,
+ loadSchedules,
+ loadScheduleOptions,
+ hideAddButton,
+}) {
const [selected, setSelected] = useState([]);
const location = useLocation();
const {
- result: { schedules, itemCount },
+ result: { schedules, itemCount, actions },
error: contentError,
isLoading,
request: fetchSchedules,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
- const response = loadSchedules(params);
- const {
- data: { count, results },
- } = await response;
- return { itemCount: count, schedules: results };
- }, [location, loadSchedules]),
+ const [
+ {
+ data: { count, results },
+ },
+ scheduleActions,
+ ] = await Promise.all([loadSchedules(params), loadScheduleOptions()]);
+ return {
+ schedules: results,
+ itemCount: count,
+ actions: scheduleActions.data.actions,
+ };
+ }, [location, loadSchedules, loadScheduleOptions]),
{
schedules: [],
itemCount: 0,
+ actions: {},
}
);
@@ -84,6 +98,11 @@ function ScheduleList({ i18n, loadSchedules }) {
setSelected([]);
};
+ const canAdd =
+ actions &&
+ Object.prototype.hasOwnProperty.call(actions, 'POST') &&
+ !hideAddButton;
+
return (
<>
,
+ ]
+ : []),
SchedulesAPI.read(params);
+const loadScheduleOptions = () => SchedulesAPI.readOptions();
describe('ScheduleList', () => {
let wrapper;
@@ -21,11 +33,12 @@ describe('ScheduleList', () => {
describe('read call successful', () => {
beforeAll(async () => {
- SchedulesAPI.read.mockResolvedValue({ data: mockSchedules });
- const loadSchedules = params => SchedulesAPI.read(params);
await act(async () => {
wrapper = mountWithContexts(
-
+
);
});
wrapper.update();
@@ -40,6 +53,10 @@ describe('ScheduleList', () => {
expect(wrapper.find('ScheduleListItem').length).toBe(5);
});
+ test('should show add button', () => {
+ expect(wrapper.find('ToolbarAddButton').length).toBe(1);
+ });
+
test('should check and uncheck the row item', async () => {
expect(
wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked
@@ -153,11 +170,32 @@ describe('ScheduleList', () => {
});
});
+ describe('hidden add button', () => {
+ test('should hide add button when flag is passed', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('ToolbarAddButton').length).toBe(0);
+ });
+ });
+
describe('read call unsuccessful', () => {
test('should show content error when read call unsuccessful', async () => {
SchedulesAPI.read.mockRejectedValue(new Error());
await act(async () => {
- wrapper = mountWithContexts();
+ wrapper = mountWithContexts(
+
+ );
});
wrapper.update();
expect(wrapper.find('ContentError').length).toBe(1);
diff --git a/awx/ui_next/src/components/ScheduleList/ScheduleListItem.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.jsx
similarity index 95%
rename from awx/ui_next/src/components/ScheduleList/ScheduleListItem.jsx
rename to awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.jsx
index 980cecfac5..ff2b3d05b5 100644
--- a/awx/ui_next/src/components/ScheduleList/ScheduleListItem.jsx
+++ b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.jsx
@@ -15,16 +15,16 @@ import {
} from '@patternfly/react-core';
import { PencilAltIcon } from '@patternfly/react-icons';
import { DetailList, Detail } from '@components/DetailList';
+import { ScheduleToggle } from '@components/Schedule';
import styled from 'styled-components';
import { Schedule } from '@types';
import { formatDateString } from '@util/dates';
-import ScheduleToggle from './ScheduleToggle';
const DataListAction = styled(_DataListAction)`
align-items: center;
display: grid;
grid-gap: 16px;
- grid-template-columns: auto 40px;
+ grid-template-columns: 92px 40px;
`;
function ScheduleListItem({ i18n, isSelected, onSelect, schedule }) {
@@ -104,7 +104,7 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule }) {
key="actions"
>
- {schedule.summary_fields.user_capabilities.edit && (
+ {schedule.summary_fields.user_capabilities.edit ? (
+ ) : (
+ ''
)}
,
]}
diff --git a/awx/ui_next/src/components/ScheduleList/ScheduleListItem.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.test.jsx
similarity index 100%
rename from awx/ui_next/src/components/ScheduleList/ScheduleListItem.test.jsx
rename to awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.test.jsx
diff --git a/awx/ui_next/src/components/ScheduleList/index.js b/awx/ui_next/src/components/Schedule/ScheduleList/index.js
similarity index 100%
rename from awx/ui_next/src/components/ScheduleList/index.js
rename to awx/ui_next/src/components/Schedule/ScheduleList/index.js
diff --git a/awx/ui_next/src/components/Schedule/ScheduleOccurrences/ScheduleOccurrences.jsx b/awx/ui_next/src/components/Schedule/ScheduleOccurrences/ScheduleOccurrences.jsx
new file mode 100644
index 0000000000..13e7835eec
--- /dev/null
+++ b/awx/ui_next/src/components/Schedule/ScheduleOccurrences/ScheduleOccurrences.jsx
@@ -0,0 +1,79 @@
+import React, { useState } from 'react';
+import { shape } from 'prop-types';
+import styled from 'styled-components';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { formatDateString, formatDateStringUTC } from '@util/dates';
+import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
+import { DetailName, DetailValue } from '@components/DetailList';
+import MultiButtonToggle from '@components/MultiButtonToggle';
+
+const OccurrencesLabel = styled.div`
+ display: inline-block;
+ font-size: var(--pf-c-form__label--FontSize);
+ font-weight: var(--pf-c-form__label--FontWeight);
+ line-height: var(--pf-c-form__label--LineHeight);
+ color: var(--pf-c-form__label--Color);
+
+ span:first-of-type {
+ font-weight: var(--pf-global--FontWeight--bold);
+ margin-right: 10px;
+ }
+`;
+
+function ScheduleOccurrences({ preview = { local: [], utc: [] }, i18n }) {
+ const [mode, setMode] = useState('local');
+
+ if (preview.local.length < 2) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+
+
+ {i18n._(t`Occurrences`)}
+ {i18n._(t`(Limited to first 10)`)}
+
+
+
+ setMode(newMode)}
+ />
+
+
+
+
+ {preview[mode].map(dateStr => (
+
+ {mode === 'local'
+ ? formatDateString(dateStr)
+ : formatDateStringUTC(dateStr)}
+
+ ))}
+
+ >
+ );
+}
+
+ScheduleOccurrences.propTypes = {
+ preview: shape(),
+};
+
+ScheduleOccurrences.defaultProps = {
+ preview: { local: [], utc: [] },
+};
+
+export default withI18n()(ScheduleOccurrences);
diff --git a/awx/ui_next/src/components/Schedule/ScheduleOccurrences/ScheduleOccurrences.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleOccurrences/ScheduleOccurrences.test.jsx
new file mode 100644
index 0000000000..46cc795995
--- /dev/null
+++ b/awx/ui_next/src/components/Schedule/ScheduleOccurrences/ScheduleOccurrences.test.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import ScheduleOccurrences from './ScheduleOccurrences';
+
+describe('', () => {
+ let wrapper;
+ describe('At least two dates passed in', () => {
+ beforeAll(() => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ afterAll(() => {
+ wrapper.unmount();
+ });
+ test('Local option initially set', async () => {
+ expect(wrapper.find('MultiButtonToggle').props().value).toBe('local');
+ });
+ test('It renders the correct number of dates', async () => {
+ expect(wrapper.find('dd').children().length).toBe(2);
+ });
+ test('Clicking UTC button toggles the dates to utc', async () => {
+ wrapper.find('button[aria-label="UTC"]').simulate('click');
+ expect(wrapper.find('MultiButtonToggle').props().value).toBe('utc');
+ expect(wrapper.find('dd').children().length).toBe(2);
+ expect(
+ wrapper
+ .find('dd')
+ .children()
+ .at(0)
+ .text()
+ ).toBe('3/16/2020, 4:00:00 AM');
+ expect(
+ wrapper
+ .find('dd')
+ .children()
+ .at(1)
+ .text()
+ ).toBe('3/30/2020, 4:00:00 AM');
+ });
+ });
+ describe('Only one date passed in', () => {
+ test('Component should not render chldren', async () => {
+ wrapper = mountWithContexts(
+
+ );
+ expect(wrapper.find('ScheduleOccurrences').children().length).toBe(0);
+ wrapper.unmount();
+ });
+ });
+});
diff --git a/awx/ui_next/src/components/Schedule/ScheduleOccurrences/index.js b/awx/ui_next/src/components/Schedule/ScheduleOccurrences/index.js
new file mode 100644
index 0000000000..2b21bebcce
--- /dev/null
+++ b/awx/ui_next/src/components/Schedule/ScheduleOccurrences/index.js
@@ -0,0 +1 @@
+export { default } from './ScheduleOccurrences';
diff --git a/awx/ui_next/src/components/ScheduleList/ScheduleToggle.jsx b/awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.jsx
similarity index 100%
rename from awx/ui_next/src/components/ScheduleList/ScheduleToggle.jsx
rename to awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.jsx
diff --git a/awx/ui_next/src/components/ScheduleList/ScheduleToggle.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.test.jsx
similarity index 100%
rename from awx/ui_next/src/components/ScheduleList/ScheduleToggle.test.jsx
rename to awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.test.jsx
diff --git a/awx/ui_next/src/components/Schedule/ScheduleToggle/index.js b/awx/ui_next/src/components/Schedule/ScheduleToggle/index.js
new file mode 100644
index 0000000000..65573a4fde
--- /dev/null
+++ b/awx/ui_next/src/components/Schedule/ScheduleToggle/index.js
@@ -0,0 +1 @@
+export { default } from './ScheduleToggle';
diff --git a/awx/ui_next/src/components/Schedule/Schedules.jsx b/awx/ui_next/src/components/Schedule/Schedules.jsx
new file mode 100644
index 0000000000..4866aba404
--- /dev/null
+++ b/awx/ui_next/src/components/Schedule/Schedules.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { withI18n } from '@lingui/react';
+import { Switch, Route, useRouteMatch } from 'react-router-dom';
+import { Schedule, ScheduleList } from '@components/Schedule';
+
+function Schedules({
+ setBreadcrumb,
+ unifiedJobTemplate,
+ loadSchedules,
+ loadScheduleOptions,
+}) {
+ const match = useRouteMatch();
+
+ return (
+
+ (
+
+ )}
+ />
+ {
+ return (
+
+ );
+ }}
+ />
+
+ );
+}
+
+export { Schedules as _Schedules };
+export default withI18n()(Schedules);
diff --git a/awx/ui_next/src/components/Schedule/Schedules.test.jsx b/awx/ui_next/src/components/Schedule/Schedules.test.jsx
new file mode 100644
index 0000000000..4589403c01
--- /dev/null
+++ b/awx/ui_next/src/components/Schedule/Schedules.test.jsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import Schedules from './Schedules';
+
+describe('', () => {
+ test('initially renders successfully', async () => {
+ let wrapper;
+ const history = createMemoryHistory({
+ initialEntries: ['/templates/job_template/1/schedules'],
+ });
+ const jobTemplate = { id: 1, name: 'Mock JT' };
+
+ await act(async () => {
+ wrapper = mountWithContexts(
+ {}}
+ jobTemplate={jobTemplate}
+ loadSchedules={() => {}}
+ loadScheduleOptions={() => {}}
+ />,
+
+ {
+ context: {
+ router: { history, route: { location: history.location } },
+ },
+ }
+ );
+ });
+ expect(wrapper.length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/components/ScheduleList/data.schedules.json b/awx/ui_next/src/components/Schedule/data.schedules.json
similarity index 100%
rename from awx/ui_next/src/components/ScheduleList/data.schedules.json
rename to awx/ui_next/src/components/Schedule/data.schedules.json
diff --git a/awx/ui_next/src/components/Schedule/index.js b/awx/ui_next/src/components/Schedule/index.js
new file mode 100644
index 0000000000..f734f868d2
--- /dev/null
+++ b/awx/ui_next/src/components/Schedule/index.js
@@ -0,0 +1,6 @@
+export { default as Schedule } from './Schedule';
+export { default as Schedules } from './Schedules';
+export { default as ScheduleList } from './ScheduleList';
+export { default as ScheduleOccurrences } from './ScheduleOccurrences';
+export { default as ScheduleToggle } from './ScheduleToggle';
+export { default as ScheduleDetail } from './ScheduleDetail';
diff --git a/awx/ui_next/src/screens/Credential/CredentialList/CredentialListItem.jsx b/awx/ui_next/src/screens/Credential/CredentialList/CredentialListItem.jsx
index 9b519bfcb1..a2d9feaae5 100644
--- a/awx/ui_next/src/screens/Credential/CredentialList/CredentialListItem.jsx
+++ b/awx/ui_next/src/screens/Credential/CredentialList/CredentialListItem.jsx
@@ -5,7 +5,7 @@ import { t } from '@lingui/macro';
import { Link } from 'react-router-dom';
import {
Button,
- DataListAction,
+ DataListAction as _DataListAction,
DataListCell,
DataListCheck,
DataListItem,
@@ -14,8 +14,15 @@ import {
Tooltip,
} from '@patternfly/react-core';
import { PencilAltIcon } from '@patternfly/react-icons';
-
import { Credential } from '@types';
+import styled from 'styled-components';
+
+const DataListAction = styled(_DataListAction)`
+ align-items: center;
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: 40px;
+`;
function CredentialListItem({
credential,
@@ -57,7 +64,7 @@ function CredentialListItem({
aria-labelledby={labelId}
id={labelId}
>
- {canEdit && (
+ {canEdit ? (
+ ) : (
+ ''
)}
diff --git a/awx/ui_next/src/screens/Host/Host.jsx b/awx/ui_next/src/screens/Host/Host.jsx
index 37096fc024..2a8f286bd2 100644
--- a/awx/ui_next/src/screens/Host/Host.jsx
+++ b/awx/ui_next/src/screens/Host/Host.jsx
@@ -10,7 +10,6 @@ import {
useLocation,
} from 'react-router-dom';
import { Card, CardActions } from '@patternfly/react-core';
-import { CaretLeftIcon } from '@patternfly/react-icons';
import { TabbedCardHeader } from '@components/Card';
import CardCloseButton from '@components/CardCloseButton';
@@ -24,20 +23,13 @@ import HostEdit from './HostEdit';
import HostGroups from './HostGroups';
import { HostsAPI } from '@api';
-function Host({ inventory, i18n, setBreadcrumb }) {
+function Host({ i18n, setBreadcrumb }) {
const [host, setHost] = useState(null);
const [contentError, setContentError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(true);
const location = useLocation();
- const hostsMatch = useRouteMatch('/hosts/:id');
- const inventoriesMatch = useRouteMatch(
- '/inventories/inventory/:id/hosts/:hostId'
- );
- const baseUrl = hostsMatch ? hostsMatch.url : inventoriesMatch.url;
- const hostListUrl = hostsMatch
- ? '/hosts'
- : `/inventories/inventory/${inventoriesMatch.params.id}/hosts`;
+ const match = useRouteMatch('/hosts/:id');
useEffect(() => {
(async () => {
@@ -45,17 +37,10 @@ function Host({ inventory, i18n, setBreadcrumb }) {
setHasContentLoading(true);
try {
- const hostId = hostsMatch
- ? hostsMatch.params.id
- : inventoriesMatch.params.hostId;
- const { data } = await HostsAPI.readDetail(hostId);
- setHost(data);
+ const { data } = await HostsAPI.readDetail(match.params.id);
- if (hostsMatch) {
- setBreadcrumb(data);
- } else if (inventoriesMatch) {
- setBreadcrumb(inventory, data);
- }
+ setHost(data);
+ setBreadcrumb(data);
} catch (error) {
setContentError(error);
} finally {
@@ -67,44 +52,31 @@ function Host({ inventory, i18n, setBreadcrumb }) {
const tabsArray = [
{
name: i18n._(t`Details`),
- link: `${baseUrl}/details`,
+ link: `${match.url}/details`,
id: 0,
},
{
name: i18n._(t`Facts`),
- link: `${baseUrl}/facts`,
+ link: `${match.url}/facts`,
id: 1,
},
{
name: i18n._(t`Groups`),
- link: `${baseUrl}/groups`,
+ link: `${match.url}/groups`,
id: 2,
},
{
name: i18n._(t`Completed Jobs`),
- link: `${baseUrl}/completed_jobs`,
+ link: `${match.url}/completed_jobs`,
id: 3,
},
];
- if (inventoriesMatch) {
- tabsArray.unshift({
- name: (
- <>
-
- {i18n._(t`Back to Hosts`)}
- >
- ),
- link: hostListUrl,
- id: 99,
- });
- }
-
let cardHeader = (
-
+
);
@@ -124,7 +96,7 @@ function Host({ inventory, i18n, setBreadcrumb }) {
{contentError.response && contentError.response.status === 404 && (
{i18n._(`Host not found.`)}{' '}
- {i18n._(`View all Hosts.`)}
+ {i18n._(`View all Hosts.`)}
)}
@@ -132,72 +104,35 @@ function Host({ inventory, i18n, setBreadcrumb }) {
);
}
- const redirect = hostsMatch ? (
-
- ) : (
-
- );
-
return (
{cardHeader}
- {redirect}
- {host && (
-
- setHost(newHost)}
- />
-
- )}
- {host && (
- }
- />
- )}
- {host && (
- }
- />
- )}
- {host && (
- }
- />
- )}
- {host?.id && (
-
+
+ {host && [
+
+
+ ,
+
+
+ ,
+
+
+ ,
+
+
+ ,
+
-
- )}
+ ,
+ ]}
!hasContentLoading && (
-
+
{i18n._(`View Host Details`)}
diff --git a/awx/ui_next/src/screens/Host/Host.test.jsx b/awx/ui_next/src/screens/Host/Host.test.jsx
index 8d5b83d2ef..64c188fc06 100644
--- a/awx/ui_next/src/screens/Host/Host.test.jsx
+++ b/awx/ui_next/src/screens/Host/Host.test.jsx
@@ -3,53 +3,41 @@ import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { HostsAPI } from '@api';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
-import mockDetails from './data.host.json';
+import mockHost from './data.host.json';
import Host from './Host';
jest.mock('@api');
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useRouteMatch: () => ({
+ url: '/hosts/1',
+ params: { id: 1 },
+ }),
+}));
+
+HostsAPI.readDetail.mockResolvedValue({
+ data: { ...mockHost },
+});
describe('', () => {
let wrapper;
let history;
- HostsAPI.readDetail.mockResolvedValue({
- data: { ...mockDetails },
+ beforeEach(async () => {
+ await act(async () => {
+ wrapper = mountWithContexts( {}} />);
+ });
});
afterEach(() => {
wrapper.unmount();
});
- test('initially renders succesfully', async () => {
- history = createMemoryHistory({
- initialEntries: ['/hosts/1/edit'],
+ test('should render expected tabs', async () => {
+ const expectedTabs = ['Details', 'Facts', 'Groups', 'Completed Jobs'];
+ wrapper.find('RoutedTabs li').forEach((tab, index) => {
+ expect(tab.text()).toEqual(expectedTabs[index]);
});
-
- await act(async () => {
- wrapper = mountWithContexts( {}} />, {
- context: { router: { history } },
- });
- });
- await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
- expect(wrapper.find('Host').length).toBe(1);
- });
-
- test('should render "Back to Hosts" tab when navigating from inventories', async () => {
- history = createMemoryHistory({
- initialEntries: ['/inventories/inventory/1/hosts/1'],
- });
- await act(async () => {
- wrapper = mountWithContexts( {}} />, {
- context: { router: { history } },
- });
- });
- await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
- expect(
- wrapper
- .find('RoutedTabs li')
- .first()
- .text()
- ).toBe('Back to Hosts');
});
test('should show content error when api throws error on initial render', async () => {
diff --git a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx
index 422cabb721..122ce7dd27 100644
--- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx
+++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx
@@ -1,34 +1,24 @@
import React, { useState } from 'react';
-import { useHistory, useRouteMatch } from 'react-router-dom';
+import { useHistory } from 'react-router-dom';
import { CardBody } from '@components/Card';
+import HostForm from '@components/HostForm';
import { HostsAPI } from '@api';
-import HostForm from '../shared';
function HostAdd() {
const [formError, setFormError] = useState(null);
const history = useHistory();
- const hostsMatch = useRouteMatch('/hosts');
- const inventoriesMatch = useRouteMatch('/inventories/inventory/:id/hosts');
- const url = hostsMatch ? hostsMatch.url : inventoriesMatch.url;
const handleSubmit = async formData => {
- const values = {
- ...formData,
- inventory: inventoriesMatch
- ? inventoriesMatch.params.id
- : formData.inventory,
- };
-
try {
- const { data: response } = await HostsAPI.create(values);
- history.push(`${url}/${response.id}/details`);
+ const { data: response } = await HostsAPI.create(formData);
+ history.push(`/hosts/${response.id}/details`);
} catch (error) {
setFormError(error);
}
};
const handleCancel = () => {
- history.push(`${url}`);
+ history.push(`/hosts`);
};
return (
diff --git a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx
index ebb302fc42..1096b83ca6 100644
--- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx
+++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx
@@ -1,27 +1,32 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
-import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
import HostAdd from './HostAdd';
import { HostsAPI } from '@api';
jest.mock('@api');
+const hostData = {
+ name: 'new name',
+ description: 'new description',
+ inventory: 1,
+ variables: '---\nfoo: bar',
+};
+
+HostsAPI.create.mockResolvedValue({
+ data: {
+ ...hostData,
+ id: 5,
+ },
+});
+
describe('', () => {
let wrapper;
let history;
- const hostData = {
- name: 'new name',
- description: 'new description',
- inventory: 1,
- variables: '---\nfoo: bar',
- };
-
beforeEach(async () => {
- history = createMemoryHistory({
- initialEntries: ['/hosts/1/add'],
- });
+ history = createMemoryHistory();
await act(async () => {
wrapper = mountWithContexts(, {
context: { router: { history } },
@@ -29,13 +34,12 @@ describe('', () => {
});
});
+ afterEach(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
test('handleSubmit should post to api', async () => {
- HostsAPI.create.mockResolvedValueOnce({
- data: {
- ...hostData,
- id: 5,
- },
- });
await act(async () => {
wrapper.find('HostForm').prop('handleSubmit')(hostData);
});
@@ -43,21 +47,31 @@ describe('', () => {
});
test('should navigate to hosts list when cancel is clicked', async () => {
- wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
+ });
expect(history.location.pathname).toEqual('/hosts');
});
test('successful form submission should trigger redirect', async () => {
- HostsAPI.create.mockResolvedValueOnce({
- data: {
- ...hostData,
- id: 5,
- },
- });
- await waitForElement(wrapper, 'button[aria-label="Save"]');
await act(async () => {
wrapper.find('HostForm').invoke('handleSubmit')(hostData);
});
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
expect(history.location.pathname).toEqual('/hosts/5/details');
});
+
+ test('failed form submission should show an error message', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ HostsAPI.create.mockImplementationOnce(() => Promise.reject(error));
+ await act(async () => {
+ wrapper.find('HostForm').invoke('handleSubmit')(hostData);
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ });
});
diff --git a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx
index 630e18d19e..41a2dec2a8 100644
--- a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx
+++ b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
-import { Link, useHistory, useParams, useLocation } from 'react-router-dom';
+import { Link, useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Host } from '@types';
@@ -14,42 +14,36 @@ import DeleteButton from '@components/DeleteButton';
import { HostsAPI } from '@api';
import HostToggle from '@components/HostToggle';
-function HostDetail({ host, i18n, onUpdateHost }) {
+function HostDetail({ i18n, host }) {
const {
created,
description,
id,
modified,
name,
+ variables,
summary_fields: {
inventory,
recent_jobs,
- kind,
created_by,
modified_by,
user_capabilities,
},
} = host;
- const history = useHistory();
- const { pathname } = useLocation();
- const { id: inventoryId, hostId: inventoryHostId } = useParams();
const [isLoading, setIsloading] = useState(false);
const [deletionError, setDeletionError] = useState(false);
-
- const recentPlaybookJobs = recent_jobs.map(job => ({ ...job, type: 'job' }));
+ const history = useHistory();
const handleHostDelete = async () => {
setIsloading(true);
try {
await HostsAPI.destroy(id);
- setIsloading(false);
- const url = pathname.startsWith('/inventories')
- ? `/inventories/inventory/${inventoryId}/hosts/`
- : `/hosts`;
- history.push(url);
+ history.push('/hosts');
} catch (err) {
setDeletionError(err);
+ } finally {
+ setIsloading(false);
}
};
@@ -66,40 +60,27 @@ function HostDetail({ host, i18n, onUpdateHost }) {
);
}
+
+ const recentPlaybookJobs = recent_jobs.map(job => ({ ...job, type: 'job' }));
return (
-
- onUpdateHost({
- ...host,
- enabled,
- })
- }
- css="padding-bottom: 40px"
- />
+
}
label={i18n._(t`Activity`)}
+ value={}
/>
- {inventory && (
-
- {inventory.name}
-
- }
- />
- )}
+
+ {inventory.name}
+
+ }
+ />
- {user_capabilities && user_capabilities.edit && (
+ {user_capabilities?.edit && (
)}
- {user_capabilities && user_capabilities.delete && (
+ {user_capabilities?.delete && (
handleHostDelete()}
modalTitle={i18n._(t`Delete Host`)}
- name={host.name}
+ name={name}
/>
)}
+ {deletionError && (
+ setDeletionError(null)}
+ >
+ {i18n._(t`Failed to delete host.`)}
+
+
+ )}
);
}
diff --git a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx
index 0f1ccc037a..d809bf1732 100644
--- a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx
+++ b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx
@@ -1,66 +1,88 @@
import React from 'react';
-
+import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
-
import HostDetail from './HostDetail';
+import { HostsAPI } from '@api';
+
+import mockHost from '../data.host.json';
jest.mock('@api');
describe('', () => {
- const mockHost = {
- id: 1,
- name: 'Foo',
- description: 'Bar',
- inventory: 1,
- created: '2015-07-07T17:21:26.429745Z',
- modified: '2019-08-11T19:47:37.980466Z',
- variables: '---',
- summary_fields: {
- inventory: {
- id: 1,
- name: 'test inventory',
- },
- user_capabilities: {
- edit: true,
- },
- recent_jobs: [],
- },
- };
+ let wrapper;
- test('initially renders succesfully', () => {
- mountWithContexts();
+ describe('User has edit permissions', () => {
+ beforeAll(() => {
+ wrapper = mountWithContexts();
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('should render Details', async () => {
+ function assertDetail(label, value) {
+ expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
+ expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
+ }
+
+ assertDetail('Name', 'localhost');
+ assertDetail('Description', 'a good description');
+ assertDetail('Inventory', 'Mikes Inventory');
+ assertDetail('Created', '10/28/2019, 9:26:54 PM');
+ assertDetail('Last Modified', '10/29/2019, 8:18:41 PM');
+ });
+
+ test('should show edit button for users with edit permission', () => {
+ const editButton = wrapper.find('Button[aria-label="edit"]');
+ expect(editButton.text()).toEqual('Edit');
+ expect(editButton.prop('to')).toBe('/hosts/2/edit');
+ });
+
+ test('expected api call is made for delete', async () => {
+ await act(async () => {
+ wrapper.find('DeleteButton').invoke('onConfirm')();
+ });
+ expect(HostsAPI.destroy).toHaveBeenCalledTimes(1);
+ });
+
+ test('Error dialog shown for failed deletion', async () => {
+ HostsAPI.destroy.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper.find('DeleteButton').invoke('onConfirm')();
+ });
+ await waitForElement(
+ wrapper,
+ 'Modal[title="Error!"]',
+ el => el.length === 1
+ );
+ await act(async () => {
+ wrapper.find('Modal[title="Error!"]').invoke('onClose')();
+ });
+ await waitForElement(
+ wrapper,
+ 'Modal[title="Error!"]',
+ el => el.length === 0
+ );
+ });
});
- test('should render Details', async () => {
- const wrapper = mountWithContexts();
- const testParams = [
- { label: 'Name', value: 'Foo' },
- { label: 'Description', value: 'Bar' },
- { label: 'Inventory', value: 'test inventory' },
- { label: 'Created', value: '7/7/2015, 5:21:26 PM' },
- { label: 'Last Modified', value: '8/11/2019, 7:47:37 PM' },
- ];
- // eslint-disable-next-line no-restricted-syntax
- for (const { label, value } of testParams) {
- // eslint-disable-next-line no-await-in-loop
- const detail = await waitForElement(wrapper, `Detail[label="${label}"]`);
- expect(detail.find('dt').text()).toBe(label);
- expect(detail.find('dd').text()).toBe(value);
- }
- });
+ describe('User has read-only permissions', () => {
+ beforeAll(() => {
+ const readOnlyHost = { ...mockHost };
+ readOnlyHost.summary_fields.user_capabilities.edit = false;
- test('should show edit button for users with edit permission', async () => {
- const wrapper = mountWithContexts();
- const editButton = wrapper.find('Button[aria-label="edit"]');
- expect(editButton.text()).toEqual('Edit');
- expect(editButton.prop('to')).toBe('/hosts/1/edit');
- });
+ wrapper = mountWithContexts();
+ });
- test('should hide edit button for users without edit permission', async () => {
- const readOnlyHost = { ...mockHost };
- readOnlyHost.summary_fields.user_capabilities.edit = false;
- const wrapper = mountWithContexts();
- await waitForElement(wrapper, 'HostDetail');
- expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0);
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('should hide edit button for users without edit permission', async () => {
+ expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0);
+ });
});
});
diff --git a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx
index d2ef0252e9..bddb692b14 100644
--- a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx
+++ b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx
@@ -1,30 +1,14 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
-import { useHistory, useRouteMatch } from 'react-router-dom';
+import { useHistory } from 'react-router-dom';
import { CardBody } from '@components/Card';
+import HostForm from '@components/HostForm';
import { HostsAPI } from '@api';
-import HostForm from '../shared';
function HostEdit({ host }) {
const [formError, setFormError] = useState(null);
- const hostsMatch = useRouteMatch('/hosts/:id/edit');
- const inventoriesMatch = useRouteMatch(
- '/inventories/inventory/:id/hosts/:hostId/edit'
- );
+ const detailsUrl = `/hosts/${host.id}/details`;
const history = useHistory();
- let detailsUrl;
-
- if (hostsMatch) {
- detailsUrl = `/hosts/${hostsMatch.params.id}/details`;
- }
-
- if (inventoriesMatch) {
- const kind =
- host.summary_fields.inventory.kind === 'smart'
- ? 'smart_inventory'
- : 'inventory';
- detailsUrl = `/inventories/${kind}/${inventoriesMatch.params.id}/hosts/${inventoriesMatch.params.hostId}/details`;
- }
const handleSubmit = async values => {
try {
diff --git a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.test.jsx b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.test.jsx
index 637dd64273..038ee90bbf 100644
--- a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.test.jsx
+++ b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.test.jsx
@@ -1,49 +1,70 @@
import React from 'react';
+import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { HostsAPI } from '@api';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import mockHost from '../data.host.json';
import HostEdit from './HostEdit';
jest.mock('@api');
describe('', () => {
- const mockData = {
- id: 1,
- name: 'Foo',
- description: 'Bar',
- inventory: 1,
- variables: '---',
- summary_fields: {
- inventory: {
- id: 1,
- name: 'test inventory',
- },
- },
+ let wrapper;
+ let history;
+
+ const updatedHostData = {
+ name: 'new name',
+ description: 'new description',
+ variables: '---\nfoo: bar',
};
- test('handleSubmit should call api update', () => {
- const wrapper = mountWithContexts();
-
- const updatedHostData = {
- name: 'new name',
- description: 'new description',
- variables: '---\nfoo: bar',
- };
- wrapper.find('HostForm').prop('handleSubmit')(updatedHostData);
-
- expect(HostsAPI.update).toHaveBeenCalledWith(1, updatedHostData);
+ beforeAll(async () => {
+ history = createMemoryHistory();
+ await act(async () => {
+ wrapper = mountWithContexts(, {
+ context: { router: { history } },
+ });
+ });
});
- test('should navigate to host detail when cancel is clicked', () => {
- const history = createMemoryHistory({
- initialEntries: ['/hosts/1/edit'],
- });
- const wrapper = mountWithContexts(, {
- context: { router: { history } },
- });
+ afterAll(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
- wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
+ test('handleSubmit should call api update', async () => {
+ await act(async () => {
+ wrapper.find('HostForm').prop('handleSubmit')(updatedHostData);
+ });
+ expect(HostsAPI.update).toHaveBeenCalledWith(2, updatedHostData);
+ });
- expect(history.location.pathname).toEqual('/hosts/1/details');
+ test('should navigate to host detail when cancel is clicked', async () => {
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
+ });
+ expect(history.location.pathname).toEqual('/hosts/2/details');
+ });
+
+ test('should navigate to host detail after successful submission', async () => {
+ await act(async () => {
+ wrapper.find('HostForm').invoke('handleSubmit')(updatedHostData);
+ });
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ expect(history.location.pathname).toEqual('/hosts/2/details');
+ });
+
+ test('failed form submission should show an error message', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ HostsAPI.update.mockImplementationOnce(() => Promise.reject(error));
+ await act(async () => {
+ wrapper.find('HostForm').invoke('handleSubmit')(mockHost);
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
});
});
diff --git a/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx b/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx
index fec58640a6..3bc9c9f949 100644
--- a/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx
+++ b/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx
@@ -24,7 +24,7 @@ const DataListAction = styled(_DataListAction)`
align-items: center;
display: grid;
grid-gap: 24px;
- grid-template-columns: min-content 40px;
+ grid-template-columns: 92px 40px;
`;
function HostListItem({ i18n, host, isSelected, onSelect, detailUrl }) {
@@ -77,7 +77,7 @@ function HostListItem({ i18n, host, isSelected, onSelect, detailUrl }) {
id={labelId}
>
- {host.summary_fields.user_capabilities.edit && (
+ {host.summary_fields.user_capabilities.edit ? (
+ ) : (
+ ''
)}
diff --git a/awx/ui_next/src/screens/Host/Hosts.test.jsx b/awx/ui_next/src/screens/Host/Hosts.test.jsx
index 0581b3371e..7db85fbb84 100644
--- a/awx/ui_next/src/screens/Host/Hosts.test.jsx
+++ b/awx/ui_next/src/screens/Host/Hosts.test.jsx
@@ -30,4 +30,23 @@ describe('', () => {
expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
wrapper.unmount();
});
+
+ test('should render Host component', () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/hosts/1'],
+ });
+
+ const match = {
+ path: '/hosts/:id',
+ url: '/hosts/1',
+ isExact: true,
+ };
+
+ const wrapper = mountWithContexts(, {
+ context: { router: { history, route: { match } } },
+ });
+
+ expect(wrapper.find('Host').length).toBe(1);
+ wrapper.unmount();
+ });
});
diff --git a/awx/ui_next/src/screens/Host/data.host.json b/awx/ui_next/src/screens/Host/data.host.json
index d2ef565610..aacc08f787 100644
--- a/awx/ui_next/src/screens/Host/data.host.json
+++ b/awx/ui_next/src/screens/Host/data.host.json
@@ -51,18 +51,6 @@
"id": 1,
"failed": false
},
- "created_by": {
- "id": 1,
- "username": "admin",
- "first_name": "",
- "last_name": ""
- },
- "modified_by": {
- "id": 1,
- "username": "admin",
- "first_name": "",
- "last_name": ""
- },
"user_capabilities": {
"edit": true,
"delete": true
diff --git a/awx/ui_next/src/screens/Host/shared/HostForm.jsx b/awx/ui_next/src/screens/Host/shared/HostForm.jsx
deleted file mode 100644
index 7df23e70c8..0000000000
--- a/awx/ui_next/src/screens/Host/shared/HostForm.jsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import React, { useState } from 'react';
-import { func, shape } from 'prop-types';
-
-import { useRouteMatch } from 'react-router-dom';
-import { Formik, useField } from 'formik';
-import { withI18n } from '@lingui/react';
-import { t } from '@lingui/macro';
-
-import { Form, FormGroup } from '@patternfly/react-core';
-
-import FormField, {
- FormSubmitError,
- FieldTooltip,
-} from '@components/FormField';
-import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
-import { VariablesField } from '@components/CodeMirrorInput';
-import { required } from '@util/validators';
-import { InventoryLookup } from '@components/Lookup';
-import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout';
-
-function HostFormFields({ host, i18n }) {
- const [inventory, setInventory] = useState(
- host ? host.summary_fields.inventory : ''
- );
-
- const hostAddMatch = useRouteMatch('/hosts/add');
- const inventoryFieldArr = useField({
- name: 'inventory',
- validate: required(i18n._(t`Select a value for this field`), i18n),
- });
- const inventoryMeta = inventoryFieldArr[1];
- const inventoryHelpers = inventoryFieldArr[2];
-
- return (
- <>
-
-
- {hostAddMatch && (
-
-
- inventoryHelpers.setTouched()}
- tooltip={i18n._(
- t`Select the inventory that this host will belong to.`
- )}
- isValid={!inventoryMeta.touched || !inventoryMeta.error}
- helperTextInvalid={inventoryMeta.error}
- onChange={value => {
- inventoryHelpers.setValue(value.id);
- setInventory(value);
- }}
- required
- touched={inventoryMeta.touched}
- error={inventoryMeta.error}
- />
-
- )}
-
-
-
- >
- );
-}
-
-function HostForm({ handleSubmit, host, submitError, handleCancel, ...rest }) {
- return (
-
- {formik => (
-
- )}
-
- );
-}
-
-HostForm.propTypes = {
- handleSubmit: func.isRequired,
- handleCancel: func.isRequired,
- host: shape({}),
- submitError: shape({}),
-};
-
-HostForm.defaultProps = {
- host: {
- name: '',
- description: '',
- inventory: undefined,
- variables: '---\n',
- summary_fields: {
- inventory: null,
- },
- },
- submitError: null,
-};
-
-export { HostForm as _HostForm };
-export default withI18n()(HostForm);
diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx
index d7361690f6..d6bfa12f74 100644
--- a/awx/ui_next/src/screens/Inventory/Inventories.jsx
+++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx
@@ -32,8 +32,10 @@ class Inventories extends Component {
if (!inventory) {
return;
}
+
const inventoryKind =
inventory.kind === 'smart' ? 'smart_inventory' : 'inventory';
+
const breadcrumbConfig = {
'/inventories': i18n._(t`Inventories`),
'/inventories/inventory/add': i18n._(t`Create New Inventory`),
@@ -65,9 +67,7 @@ class Inventories extends Component {
t`Create New Host`
),
[`/inventories/${inventoryKind}/${inventory.id}/hosts/${nestedResource &&
- nestedResource.id}`]: i18n._(
- t`${nestedResource && nestedResource.name}`
- ),
+ nestedResource.id}`]: `${nestedResource && nestedResource.name}`,
[`/inventories/${inventoryKind}/${inventory.id}/hosts/${nestedResource &&
nestedResource.id}/edit`]: i18n._(t`Edit Details`),
[`/inventories/${inventoryKind}/${inventory.id}/hosts/${nestedResource &&
@@ -83,6 +83,10 @@ class Inventories extends Component {
nestedResource.id}/edit`]: i18n._(t`Edit Details`),
[`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource &&
nestedResource.id}/details`]: i18n._(t`Group Details`),
+ [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource &&
+ nestedResource.id}`]: `${nestedResource && nestedResource.name}`,
+ [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource &&
+ nestedResource.id}/nested_hosts`]: i18n._(t`Hosts`),
};
this.setState({ breadcrumbConfig });
};
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx
index ef5b0a7995..b1d3734a5f 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx
@@ -19,6 +19,8 @@ import ContentLoading from '@components/ContentLoading';
import { TabbedCardHeader } from '@components/Card';
import InventoryGroupEdit from '../InventoryGroupEdit/InventoryGroupEdit';
import InventoryGroupDetail from '../InventoryGroupDetail/InventoryGroupDetail';
+import InventoryGroupHosts from '../InventoryGroupHosts';
+
import { GroupsAPI } from '@api';
function InventoryGroup({ i18n, setBreadcrumb, inventory }) {
@@ -142,6 +144,12 @@ function InventoryGroup({ i18n, setBreadcrumb, inventory }) {
}}
/>,
]}
+
+
+
+ {i18n._(t`Add New Host`)}
+ ,
+
+ {i18n._(t`Add Existing Host`)}
+ ,
+ ];
+
+ return (
+ setIsOpen(prevState => !prevState)}
+ >
+ {i18n._(t`Add`)}
+
+ }
+ dropdownItems={dropdownItems}
+ />
+ );
+}
+
+AddHostDropdown.propTypes = {
+ onAddNew: func.isRequired,
+ onAddExisting: func.isRequired,
+};
+
+export default withI18n()(AddHostDropdown);
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.test.jsx
new file mode 100644
index 0000000000..c72e505ea2
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.test.jsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import AddHostDropdown from './AddHostDropdown';
+
+describe('', () => {
+ let wrapper;
+ let dropdownToggle;
+ const onAddNew = jest.fn();
+ const onAddExisting = jest.fn();
+
+ beforeEach(() => {
+ wrapper = mountWithContexts(
+
+ );
+ dropdownToggle = wrapper.find('DropdownToggle button');
+ });
+
+ test('should initially render a closed dropdown', () => {
+ expect(wrapper.find('DropdownItem').length).toBe(0);
+ });
+
+ test('should render two dropdown items', () => {
+ dropdownToggle.simulate('click');
+ expect(wrapper.find('DropdownItem').length).toBe(2);
+ });
+
+ test('should close when button re-clicked', () => {
+ dropdownToggle.simulate('click');
+ expect(wrapper.find('DropdownItem').length).toBe(2);
+ dropdownToggle.simulate('click');
+ expect(wrapper.find('DropdownItem').length).toBe(0);
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx
new file mode 100644
index 0000000000..237c9e5c93
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx
@@ -0,0 +1,162 @@
+import React, { useEffect, useCallback, useState } from 'react';
+import { useHistory, useLocation, useParams } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { getQSConfig, parseQueryString } from '@util/qs';
+import { GroupsAPI, InventoriesAPI } from '@api';
+
+import AlertModal from '@components/AlertModal';
+import DataListToolbar from '@components/DataListToolbar';
+import PaginatedDataList from '@components/PaginatedDataList';
+import useRequest from '@util/useRequest';
+import InventoryGroupHostListItem from './InventoryGroupHostListItem';
+import AddHostDropdown from './AddHostDropdown';
+
+const QS_CONFIG = getQSConfig('host', {
+ page: 1,
+ page_size: 20,
+ order_by: 'name',
+});
+
+function InventoryGroupHostList({ i18n }) {
+ const [selected, setSelected] = useState([]);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const { id: inventoryId, groupId } = useParams();
+ const location = useLocation();
+ const history = useHistory();
+
+ const {
+ result: { hosts, hostCount, actions },
+ error: contentError,
+ isLoading,
+ request: fetchHosts,
+ } = useRequest(
+ useCallback(async () => {
+ const params = parseQueryString(QS_CONFIG, location.search);
+ const [response, actionsResponse] = await Promise.all([
+ GroupsAPI.readAllHosts(groupId, params),
+ InventoriesAPI.readHostsOptions(inventoryId),
+ ]);
+
+ return {
+ hosts: response.data.results,
+ hostCount: response.data.count,
+ actions: actionsResponse.data.actions,
+ };
+ }, [groupId, inventoryId, location.search]),
+ {
+ hosts: [],
+ hostCount: 0,
+ }
+ );
+
+ useEffect(() => {
+ fetchHosts();
+ }, [fetchHosts]);
+
+ const handleSelectAll = isSelected => {
+ setSelected(isSelected ? [...hosts] : []);
+ };
+
+ const handleSelect = row => {
+ if (selected.some(s => s.id === row.id)) {
+ setSelected(selected.filter(s => s.id !== row.id));
+ } else {
+ setSelected(selected.concat(row));
+ }
+ };
+
+ const isAllSelected = selected.length > 0 && selected.length === hosts.length;
+ const canAdd =
+ actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
+ const addFormUrl = `/inventories/inventory/${inventoryId}/groups/${groupId}/nested_hosts/add`;
+
+ return (
+ <>
+ (
+ setIsModalOpen(true)}
+ onAddNew={() => history.push(addFormUrl)}
+ />,
+ ]
+ : []),
+ // TODO HOST DISASSOCIATE BUTTON
+ ]}
+ />
+ )}
+ renderItem={o => (
+ row.id === o.id)}
+ onSelect={() => handleSelect(o)}
+ />
+ )}
+ emptyStateControls={
+ canAdd && (
+ setIsModalOpen(true)}
+ onAddNew={() => history.push(addFormUrl)}
+ />
+ )
+ }
+ />
+
+ {/* DISASSOCIATE HOST MODAL PLACEHOLDER */}
+
+ {isModalOpen && (
+ setIsModalOpen(false)}
+ >
+ {/* ADD/ASSOCIATE HOST MODAL PLACEHOLDER */}
+ {i18n._(t`Host Select Modal`)}
+
+ )}
+ >
+ );
+}
+
+export default withI18n()(InventoryGroupHostList);
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx
new file mode 100644
index 0000000000..8345964e40
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx
@@ -0,0 +1,164 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import { GroupsAPI, InventoriesAPI } from '@api';
+import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
+import InventoryGroupHostList from './InventoryGroupHostList';
+import mockHosts from '../shared/data.hosts.json';
+
+jest.mock('@api/models/Groups');
+jest.mock('@api/models/Inventories');
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: () => ({
+ id: 1,
+ groupId: 2,
+ }),
+}));
+
+describe('', () => {
+ let wrapper;
+
+ beforeEach(async () => {
+ GroupsAPI.readAllHosts.mockResolvedValue({
+ data: { ...mockHosts },
+ });
+ InventoriesAPI.readHostsOptions.mockResolvedValue({
+ data: {
+ actions: {
+ GET: {},
+ POST: {},
+ },
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
+ test('initially renders successfully ', () => {
+ expect(wrapper.find('InventoryGroupHostList').length).toBe(1);
+ });
+
+ test('should fetch inventory group hosts from api and render them in the list', () => {
+ expect(GroupsAPI.readAllHosts).toHaveBeenCalled();
+ expect(InventoriesAPI.readHostsOptions).toHaveBeenCalled();
+ expect(wrapper.find('InventoryGroupHostListItem').length).toBe(3);
+ });
+
+ test('should check and uncheck the row item', async () => {
+ expect(
+ wrapper.find('DataListCheck[id="select-host-2"]').props().checked
+ ).toBe(false);
+ await act(async () => {
+ wrapper.find('DataListCheck[id="select-host-2"]').invoke('onChange')();
+ });
+ wrapper.update();
+ expect(
+ wrapper.find('DataListCheck[id="select-host-2"]').props().checked
+ ).toBe(true);
+ await act(async () => {
+ wrapper.find('DataListCheck[id="select-host-2"]').invoke('onChange')();
+ });
+ wrapper.update();
+ expect(
+ wrapper.find('DataListCheck[id="select-host-2"]').props().checked
+ ).toBe(false);
+ });
+
+ test('should check all row items when select all is checked', async () => {
+ wrapper.find('DataListCheck').forEach(el => {
+ expect(el.props().checked).toBe(false);
+ });
+ await act(async () => {
+ wrapper.find('Checkbox#select-all').invoke('onChange')(true);
+ });
+ wrapper.update();
+ wrapper.find('DataListCheck').forEach(el => {
+ expect(el.props().checked).toBe(true);
+ });
+ await act(async () => {
+ wrapper.find('Checkbox#select-all').invoke('onChange')(false);
+ });
+ wrapper.update();
+ wrapper.find('DataListCheck').forEach(el => {
+ expect(el.props().checked).toBe(false);
+ });
+ });
+
+ test('should show add dropdown button according to permissions', async () => {
+ expect(wrapper.find('AddHostDropdown').length).toBe(1);
+ InventoriesAPI.readHostsOptions.mockResolvedValueOnce({
+ data: {
+ actions: {
+ GET: {},
+ },
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('AddHostDropdown').length).toBe(0);
+ });
+
+ test('should show associate host modal when adding an existing host', () => {
+ const dropdownToggle = wrapper.find(
+ 'DropdownToggle button[aria-label="add host"]'
+ );
+ dropdownToggle.simulate('click');
+ wrapper
+ .find('DropdownItem[aria-label="add existing host"]')
+ .simulate('click');
+ expect(wrapper.find('AlertModal').length).toBe(1);
+ wrapper.find('ModalBoxCloseButton').simulate('click');
+ expect(wrapper.find('AlertModal').length).toBe(0);
+ });
+
+ test('should navigate to host add form when adding a new host', async () => {
+ GroupsAPI.readAllHosts.mockResolvedValue({
+ data: { ...mockHosts },
+ });
+ InventoriesAPI.readHostsOptions.mockResolvedValue({
+ data: {
+ actions: {
+ GET: {},
+ POST: {},
+ },
+ },
+ });
+ const history = createMemoryHistory();
+ await act(async () => {
+ wrapper = mountWithContexts(, {
+ context: {
+ router: { history },
+ },
+ });
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ const dropdownToggle = wrapper.find(
+ 'DropdownToggle button[aria-label="add host"]'
+ );
+ dropdownToggle.simulate('click');
+ wrapper.find('DropdownItem[aria-label="add new host"]').simulate('click');
+ expect(history.location.pathname).toEqual(
+ '/inventories/inventory/1/groups/2/nested_hosts/add'
+ );
+ });
+
+ test('should show content error when api throws error on initial render', async () => {
+ InventoriesAPI.readHostsOptions.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ await waitForElement(wrapper, 'ContentError', el => el.length === 1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.jsx
new file mode 100644
index 0000000000..a234b9597f
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.jsx
@@ -0,0 +1,98 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { string, bool, func } from 'prop-types';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+
+import {
+ Button,
+ DataListAction as _DataListAction,
+ DataListCell,
+ DataListCheck,
+ DataListItem,
+ DataListItemCells,
+ DataListItemRow,
+ Tooltip,
+} from '@patternfly/react-core';
+import { PencilAltIcon } from '@patternfly/react-icons';
+import HostToggle from '@components/HostToggle';
+import Sparkline from '@components/Sparkline';
+import { Host } from '@types';
+import styled from 'styled-components';
+
+const DataListAction = styled(_DataListAction)`
+ align-items: center;
+ display: grid;
+ grid-gap: 24px;
+ grid-template-columns: min-content 40px;
+`;
+
+function InventoryGroupHostListItem({
+ i18n,
+ detailUrl,
+ editUrl,
+ host,
+ isSelected,
+ onSelect,
+}) {
+ const recentPlaybookJobs = host.summary_fields.recent_jobs.map(job => ({
+ ...job,
+ type: 'job',
+ }));
+
+ const labelId = `check-action-${host.id}`;
+
+ return (
+
+
+
+
+
+ {host.name}
+
+ ,
+
+
+ ,
+ ]}
+ />
+
+
+ {host.summary_fields.user_capabilities?.edit && (
+
+
+
+ )}
+
+
+
+ );
+}
+
+InventoryGroupHostListItem.propTypes = {
+ detailUrl: string.isRequired,
+ editUrl: string.isRequired,
+ host: Host.isRequired,
+ isSelected: bool.isRequired,
+ onSelect: func.isRequired,
+};
+
+export default withI18n()(InventoryGroupHostListItem);
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.test.jsx
new file mode 100644
index 0000000000..830bd5540e
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.test.jsx
@@ -0,0 +1,57 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import InventoryGroupHostListItem from './InventoryGroupHostListItem';
+import mockHosts from '../shared/data.hosts.json';
+
+jest.mock('@api');
+
+describe('', () => {
+ let wrapper;
+ const mockHost = mockHosts.results[0];
+
+ beforeEach(() => {
+ wrapper = mountWithContexts(
+ {}}
+ />
+ );
+ });
+
+ afterEach(() => {
+ wrapper.unmount();
+ });
+
+ test('should display expected row item content', () => {
+ expect(
+ wrapper
+ .find('DataListCell')
+ .first()
+ .text()
+ ).toBe('.host-000001.group-00000.dummy');
+ expect(wrapper.find('Sparkline').length).toBe(1);
+ expect(wrapper.find('HostToggle').length).toBe(1);
+ });
+
+ test('edit button shown to users with edit capabilities', () => {
+ expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
+ });
+
+ test('edit button hidden from users without edit capabilities', () => {
+ const copyMockHost = Object.assign({}, mockHost);
+ copyMockHost.summary_fields.user_capabilities.edit = false;
+ wrapper = mountWithContexts(
+ {}}
+ />
+ );
+ expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.jsx
new file mode 100644
index 0000000000..dc3da57781
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import { Switch, Route } from 'react-router-dom';
+import InventoryGroupHostList from './InventoryGroupHostList';
+
+function InventoryGroupHosts() {
+ return (
+
+ {/* Route to InventoryGroupHostAddForm */}
+
+
+
+
+ );
+}
+
+export default InventoryGroupHosts;
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.test.jsx
new file mode 100644
index 0000000000..0a4cecc19d
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.test.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import InventoryGroupHosts from './InventoryGroupHosts';
+
+jest.mock('@api');
+
+describe('', () => {
+ let wrapper;
+
+ test('initially renders successfully', async () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/inventories/inventory/1/groups/1/nested_hosts'],
+ });
+
+ await act(async () => {
+ wrapper = mountWithContexts(, {
+ context: {
+ router: { history, route: { location: history.location } },
+ },
+ });
+ });
+ expect(wrapper.length).toBe(1);
+ expect(wrapper.find('InventoryGroupHostList').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/index.js
new file mode 100644
index 0000000000..58e24ac90e
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/index.js
@@ -0,0 +1 @@
+export { default } from './InventoryGroupHosts';
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.jsx b/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.jsx
new file mode 100644
index 0000000000..89121e8cd5
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.jsx
@@ -0,0 +1,174 @@
+import React, { useEffect, useCallback } from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import {
+ Switch,
+ Route,
+ Redirect,
+ Link,
+ useRouteMatch,
+ useLocation,
+} from 'react-router-dom';
+import useRequest from '@util/useRequest';
+
+import { HostsAPI } from '@api';
+import { Card, CardActions } from '@patternfly/react-core';
+import { CaretLeftIcon } from '@patternfly/react-icons';
+import { TabbedCardHeader } from '@components/Card';
+import CardCloseButton from '@components/CardCloseButton';
+import ContentError from '@components/ContentError';
+import ContentLoading from '@components/ContentLoading';
+import RoutedTabs from '@components/RoutedTabs';
+import JobList from '@components/JobList';
+import InventoryHostDetail from '../InventoryHostDetail';
+import InventoryHostEdit from '../InventoryHostEdit';
+
+function InventoryHost({ i18n, setBreadcrumb, inventory }) {
+ const location = useLocation();
+ const match = useRouteMatch('/inventories/inventory/:id/hosts/:hostId');
+ const hostListUrl = `/inventories/inventory/${inventory.id}/hosts`;
+
+ const {
+ result: { host },
+ error: contentError,
+ isLoading,
+ request: fetchHost,
+ } = useRequest(
+ useCallback(async () => {
+ const { data } = await HostsAPI.readDetail(match.params.hostId);
+
+ return {
+ host: data,
+ };
+ }, [match.params.hostId]), // eslint-disable-line react-hooks/exhaustive-deps
+ {
+ host: null,
+ }
+ );
+
+ useEffect(() => {
+ fetchHost();
+ }, [fetchHost]);
+
+ useEffect(() => {
+ if (inventory && host) {
+ setBreadcrumb(inventory, host);
+ }
+ }, [inventory, host, setBreadcrumb]);
+
+ const tabsArray = [
+ {
+ name: (
+ <>
+
+ {i18n._(t`Back to Hosts`)}
+ >
+ ),
+ link: `${hostListUrl}`,
+ id: 0,
+ },
+ {
+ name: i18n._(t`Details`),
+ link: `${match.url}/details`,
+ id: 1,
+ },
+ {
+ name: i18n._(t`Facts`),
+ link: `${match.url}/facts`,
+ id: 2,
+ },
+ {
+ name: i18n._(t`Groups`),
+ link: `${match.url}/groups`,
+ id: 3,
+ },
+ {
+ name: i18n._(t`Completed Jobs`),
+ link: `${match.url}/completed_jobs`,
+ id: 4,
+ },
+ ];
+
+ let cardHeader = (
+
+
+
+
+
+
+ );
+
+ if (location.pathname.endsWith('edit')) {
+ cardHeader = null;
+ }
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!isLoading && contentError) {
+ return (
+
+
+ {contentError.response && contentError.response.status === 404 && (
+
+ {i18n._(`Host not found.`)}{' '}
+
+ {i18n._(`View all Inventory Hosts.`)}
+
+
+ )}
+
+
+ );
+ }
+
+ return (
+ <>
+ {cardHeader}
+
+
+ {host &&
+ inventory && [
+
+
+ ,
+
+
+ ,
+
+
+ ,
+ ]}
+
+ !isLoading && (
+
+
+ {i18n._(`View Inventory Host Details`)}
+
+
+ )
+ }
+ />
+
+ >
+ );
+}
+
+export default withI18n()(InventoryHost);
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.test.jsx
new file mode 100644
index 0000000000..bea26df827
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryHost/InventoryHost.test.jsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import { HostsAPI } from '@api';
+import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
+import mockHost from '../shared/data.host.json';
+import InventoryHost from './InventoryHost';
+
+jest.mock('@api');
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useRouteMatch: () => ({
+ url: '/inventories/inventory/1/hosts/1',
+ params: { id: 1, hostId: 1 },
+ }),
+}));
+
+HostsAPI.readDetail.mockResolvedValue({
+ data: { ...mockHost },
+});
+
+const mockInventory = {
+ id: 1,
+ name: 'Mock Inventory',
+};
+
+describe('', () => {
+ let wrapper;
+ let history;
+
+ beforeEach(async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+ {}} />
+ );
+ });
+ });
+
+ afterEach(() => {
+ wrapper.unmount();
+ });
+
+ test('should render expected tabs', async () => {
+ const expectedTabs = [
+ 'Back to Hosts',
+ 'Details',
+ 'Facts',
+ 'Groups',
+ 'Completed Jobs',
+ ];
+ wrapper.find('RoutedTabs li').forEach((tab, index) => {
+ expect(tab.text()).toEqual(expectedTabs[index]);
+ });
+ });
+
+ test('should show content error when api throws error on initial render', async () => {
+ HostsAPI.readDetail.mockRejectedValueOnce(new Error());
+ await act(async () => {
+ wrapper = mountWithContexts(
+ {}} />
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ await waitForElement(wrapper, 'ContentError', el => el.length === 1);
+ });
+
+ test('should show content error when user attempts to navigate to erroneous route', async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/inventories/inventory/1/hosts/1/foobar'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+ {}} />,
+ { context: { router: { history } } }
+ );
+ });
+ await waitForElement(wrapper, 'ContentError', el => el.length === 1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHost/index.js b/awx/ui_next/src/screens/Inventory/InventoryHost/index.js
new file mode 100644
index 0000000000..5419035b15
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryHost/index.js
@@ -0,0 +1 @@
+export { default } from './InventoryHost';
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.jsx
new file mode 100644
index 0000000000..9afb38c90b
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.jsx
@@ -0,0 +1,42 @@
+import React, { useState } from 'react';
+import { useHistory } from 'react-router-dom';
+import { CardBody } from '@components/Card';
+import HostForm from '@components/HostForm';
+
+import { HostsAPI } from '@api';
+
+function InventoryHostAdd({ inventory }) {
+ const [formError, setFormError] = useState(null);
+ const hostsUrl = `/inventories/inventory/${inventory.id}/hosts`;
+ const history = useHistory();
+
+ const handleSubmit = async formData => {
+ try {
+ const values = {
+ ...formData,
+ inventory: inventory.id,
+ };
+ const { data: response } = await HostsAPI.create(values);
+ history.push(`${hostsUrl}/${response.id}/details`);
+ } catch (error) {
+ setFormError(error);
+ }
+ };
+
+ const handleCancel = () => {
+ history.push(hostsUrl);
+ };
+
+ return (
+
+
+
+ );
+}
+
+export default InventoryHostAdd;
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.test.jsx
new file mode 100644
index 0000000000..d17216ac10
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.test.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import InventoryHostAdd from './InventoryHostAdd';
+import mockHost from '../shared/data.host.json';
+import { HostsAPI } from '@api';
+
+jest.mock('@api');
+
+HostsAPI.create.mockResolvedValue({
+ data: {
+ ...mockHost,
+ },
+});
+
+describe('', () => {
+ let wrapper;
+ let history;
+
+ beforeAll(async () => {
+ history = createMemoryHistory();
+ await act(async () => {
+ wrapper = mountWithContexts(, {
+ context: { router: { history } },
+ });
+ });
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
+ test('handleSubmit should post to api', async () => {
+ await act(async () => {
+ wrapper.find('HostForm').prop('handleSubmit')(mockHost);
+ });
+ expect(HostsAPI.create).toHaveBeenCalledWith(mockHost);
+ });
+
+ test('should navigate to hosts list when cancel is clicked', () => {
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ expect(history.location.pathname).toEqual('/inventories/inventory/3/hosts');
+ });
+
+ test('successful form submission should trigger redirect', async () => {
+ await act(async () => {
+ wrapper.find('HostForm').invoke('handleSubmit')(mockHost);
+ });
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ expect(history.location.pathname).toEqual(
+ '/inventories/inventory/3/hosts/2/details'
+ );
+ });
+
+ test('failed form submission should show an error message', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ HostsAPI.create.mockImplementationOnce(() => Promise.reject(error));
+ await act(async () => {
+ wrapper.find('HostForm').invoke('handleSubmit')(mockHost);
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostAdd/index.js b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/index.js
new file mode 100644
index 0000000000..56bb7e05ad
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/index.js
@@ -0,0 +1 @@
+export { default } from './InventoryHostAdd';
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.jsx
new file mode 100644
index 0000000000..cfa36e4e83
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.jsx
@@ -0,0 +1,129 @@
+import React, { useState } from 'react';
+import { Link, useHistory } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { Host } from '@types';
+import { Button } from '@patternfly/react-core';
+import { CardBody, CardActionsRow } from '@components/Card';
+import AlertModal from '@components/AlertModal';
+import ErrorDetail from '@components/ErrorDetail';
+import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
+import { VariablesDetail } from '@components/CodeMirrorInput';
+import Sparkline from '@components/Sparkline';
+import DeleteButton from '@components/DeleteButton';
+import { HostsAPI } from '@api';
+import HostToggle from '@components/HostToggle';
+
+function InventoryHostDetail({ i18n, host }) {
+ const {
+ created,
+ description,
+ id,
+ modified,
+ name,
+ variables,
+ summary_fields: {
+ inventory,
+ recent_jobs,
+ created_by,
+ modified_by,
+ user_capabilities,
+ },
+ } = host;
+
+ const [isLoading, setIsloading] = useState(false);
+ const [deletionError, setDeletionError] = useState(false);
+ const history = useHistory();
+
+ const handleHostDelete = async () => {
+ setIsloading(true);
+ try {
+ await HostsAPI.destroy(id);
+ history.push(`/inventories/inventory/${inventory.id}/hosts`);
+ } catch (err) {
+ setDeletionError(err);
+ } finally {
+ setIsloading(false);
+ }
+ };
+
+ if (!isLoading && deletionError) {
+ return (
+ setDeletionError(false)}
+ >
+ {i18n._(t`Failed to delete ${name}.`)}
+
+
+ );
+ }
+
+ const recentPlaybookJobs = recent_jobs.map(job => ({ ...job, type: 'job' }));
+
+ return (
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+ {user_capabilities?.edit && (
+
+ )}
+ {user_capabilities?.delete && (
+ handleHostDelete()}
+ />
+ )}
+
+ {deletionError && (
+ setDeletionError(null)}
+ >
+ {i18n._(t`Failed to delete host.`)}
+
+
+ )}
+
+ );
+}
+
+InventoryHostDetail.propTypes = {
+ host: Host.isRequired,
+};
+
+export default withI18n()(InventoryHostDetail);
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.test.jsx
new file mode 100644
index 0000000000..66735e19cb
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.test.jsx
@@ -0,0 +1,88 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
+import InventoryHostDetail from './InventoryHostDetail';
+import { HostsAPI } from '@api';
+import mockHost from '../shared/data.host.json';
+
+jest.mock('@api');
+
+describe('', () => {
+ let wrapper;
+
+ describe('User has edit permissions', () => {
+ beforeAll(() => {
+ wrapper = mountWithContexts();
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('should render Details', async () => {
+ function assertDetail(label, value) {
+ expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
+ expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
+ }
+
+ assertDetail('Name', 'localhost');
+ assertDetail('Description', 'localhost description');
+ assertDetail('Created', '10/28/2019, 9:26:54 PM');
+ assertDetail('Last Modified', '10/29/2019, 8:18:41 PM');
+ });
+
+ test('should show edit button for users with edit permission', () => {
+ const editButton = wrapper.find('Button[aria-label="edit"]');
+ expect(editButton.text()).toEqual('Edit');
+ expect(editButton.prop('to')).toBe(
+ '/inventories/inventory/3/hosts/2/edit'
+ );
+ });
+
+ test('expected api call is made for delete', async () => {
+ await act(async () => {
+ wrapper.find('DeleteButton').invoke('onConfirm')();
+ });
+ expect(HostsAPI.destroy).toHaveBeenCalledTimes(1);
+ });
+
+ test('Error dialog shown for failed deletion', async () => {
+ HostsAPI.destroy.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper.find('DeleteButton').invoke('onConfirm')();
+ });
+ await waitForElement(
+ wrapper,
+ 'Modal[title="Error!"]',
+ el => el.length === 1
+ );
+ await act(async () => {
+ wrapper.find('Modal[title="Error!"]').invoke('onClose')();
+ });
+ await waitForElement(
+ wrapper,
+ 'Modal[title="Error!"]',
+ el => el.length === 0
+ );
+ });
+ });
+
+ describe('User has read-only permissions', () => {
+ beforeAll(() => {
+ const readOnlyHost = { ...mockHost };
+ readOnlyHost.summary_fields.user_capabilities.edit = false;
+
+ wrapper = mountWithContexts();
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('should hide edit button for users without edit permission', async () => {
+ expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0);
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostDetail/index.js b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/index.js
new file mode 100644
index 0000000000..df9deaf20d
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/index.js
@@ -0,0 +1 @@
+export { default } from './InventoryHostDetail';
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.jsx
new file mode 100644
index 0000000000..c7f0845bd4
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.jsx
@@ -0,0 +1,44 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { useHistory } from 'react-router-dom';
+import { CardBody } from '@components/Card';
+import HostForm from '@components/HostForm';
+
+import { HostsAPI } from '@api';
+
+function InventoryHostEdit({ host, inventory }) {
+ const [formError, setFormError] = useState(null);
+ const detailsUrl = `/inventories/inventory/${inventory.id}/hosts/${host.id}/details`;
+ const history = useHistory();
+
+ const handleSubmit = async values => {
+ try {
+ await HostsAPI.update(host.id, values);
+ history.push(detailsUrl);
+ } catch (error) {
+ setFormError(error);
+ }
+ };
+
+ const handleCancel = () => {
+ history.push(detailsUrl);
+ };
+
+ return (
+
+
+
+ );
+}
+
+InventoryHostEdit.propTypes = {
+ host: PropTypes.shape().isRequired,
+};
+
+export default InventoryHostEdit;
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.test.jsx
new file mode 100644
index 0000000000..f6a6ccb849
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryHostEdit/InventoryHostEdit.test.jsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import { HostsAPI } from '@api';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import InventoryHostEdit from './InventoryHostEdit';
+import mockHost from '../shared/data.host.json';
+
+jest.mock('@api');
+
+describe('', () => {
+ let wrapper;
+ let history;
+
+ const updatedHostData = {
+ name: 'new name',
+ description: 'new description',
+ variables: '---\nfoo: bar',
+ };
+
+ beforeAll(async () => {
+ history = createMemoryHistory();
+ await act(async () => {
+ wrapper = mountWithContexts(
+ ,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
+ test('handleSubmit should call api update', async () => {
+ await act(async () => {
+ wrapper.find('HostForm').prop('handleSubmit')(updatedHostData);
+ });
+ expect(HostsAPI.update).toHaveBeenCalledWith(2, updatedHostData);
+ });
+
+ test('should navigate to inventory host detail when cancel is clicked', async () => {
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
+ });
+ expect(history.location.pathname).toEqual(
+ '/inventories/inventory/123/hosts/2/details'
+ );
+ });
+
+ test('should navigate to inventory host detail after successful submission', async () => {
+ await act(async () => {
+ wrapper.find('HostForm').invoke('handleSubmit')(updatedHostData);
+ });
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ expect(history.location.pathname).toEqual(
+ '/inventories/inventory/123/hosts/2/details'
+ );
+ });
+
+ test('failed form submission should show an error message', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ HostsAPI.update.mockImplementationOnce(() => Promise.reject(error));
+ await act(async () => {
+ wrapper.find('HostForm').invoke('handleSubmit')(mockHost);
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostEdit/index.js b/awx/ui_next/src/screens/Inventory/InventoryHostEdit/index.js
new file mode 100644
index 0000000000..428da2e09c
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryHostEdit/index.js
@@ -0,0 +1 @@
+export { default } from './InventoryHostEdit';
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx
index 3257024cef..a15fbebf6d 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx
@@ -1,28 +1,22 @@
import React from 'react';
import { Switch, Route } from 'react-router-dom';
-import Host from '../../Host/Host';
+import InventoryHost from '../InventoryHost';
+import InventoryHostAdd from '../InventoryHostAdd';
import InventoryHostList from './InventoryHostList';
-import HostAdd from '../../Host/HostAdd';
function InventoryHosts({ setBreadcrumb, inventory }) {
return (
-
+
+
+
+
+
+
+
- (
-
- )}
- />
- }
- />
);
}
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.test.jsx
new file mode 100644
index 0000000000..140a6e57ea
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.test.jsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { createMemoryHistory } from 'history';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import InventoryHosts from './InventoryHosts';
+
+describe('', () => {
+ test('should render inventory host list', () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/inventories/inventory/1/hosts'],
+ });
+
+ const match = {
+ path: '/inventories/inventory/:id/hosts',
+ url: '/inventories/inventory/1/hosts',
+ isExact: true,
+ };
+
+ const wrapper = mountWithContexts(, {
+ context: { router: { history, route: { match } } },
+ });
+
+ expect(wrapper.find('InventoryHostList').length).toBe(1);
+ wrapper.unmount();
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx
index ea5e445994..3edd54e169 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx
@@ -3,7 +3,7 @@ import { string, bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import {
Button,
- DataListAction,
+ DataListAction as _DataListAction,
DataListCell,
DataListCheck,
DataListItem,
@@ -13,10 +13,18 @@ import {
} from '@patternfly/react-core';
import { t } from '@lingui/macro';
import { Link } from 'react-router-dom';
+import styled from 'styled-components';
import { PencilAltIcon } from '@patternfly/react-icons';
import { Inventory } from '@types';
+const DataListAction = styled(_DataListAction)`
+ align-items: center;
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: 40px;
+`;
+
class InventoryListItem extends React.Component {
static propTypes = {
inventory: Inventory.isRequired,
@@ -60,7 +68,7 @@ class InventoryListItem extends React.Component {
aria-labelledby={labelId}
id={labelId}
>
- {inventory.summary_fields.user_capabilities.edit && (
+ {inventory.summary_fields.user_capabilities.edit ? (
+ ) : (
+ ''
)}
diff --git a/awx/ui_next/src/screens/Inventory/shared/data.host.json b/awx/ui_next/src/screens/Inventory/shared/data.host.json
new file mode 100644
index 0000000000..a4975ad01b
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/shared/data.host.json
@@ -0,0 +1,86 @@
+{
+ "id": 2,
+ "type": "host",
+ "url": "/api/v2/hosts/2/",
+ "related": {
+ "created_by": "/api/v2/users/1/",
+ "modified_by": "/api/v2/users/1/",
+ "variable_data": "/api/v2/hosts/2/variable_data/",
+ "groups": "/api/v2/hosts/2/groups/",
+ "all_groups": "/api/v2/hosts/2/all_groups/",
+ "job_events": "/api/v2/hosts/2/job_events/",
+ "job_host_summaries": "/api/v2/hosts/2/job_host_summaries/",
+ "activity_stream": "/api/v2/hosts/2/activity_stream/",
+ "inventory_sources": "/api/v2/hosts/2/inventory_sources/",
+ "smart_inventories": "/api/v2/hosts/2/smart_inventories/",
+ "ad_hoc_commands": "/api/v2/hosts/2/ad_hoc_commands/",
+ "ad_hoc_command_events": "/api/v2/hosts/2/ad_hoc_command_events/",
+ "insights": "/api/v2/hosts/2/insights/",
+ "ansible_facts": "/api/v2/hosts/2/ansible_facts/",
+ "inventory": "/api/v2/inventories/3/",
+ "last_job": "/api/v2/jobs/3/",
+ "last_job_host_summary": "/api/v2/job_host_summaries/1/"
+ },
+ "summary_fields": {
+ "inventory": {
+ "id": 3,
+ "name": "Mikes Inventory",
+ "description": "",
+ "has_active_failures": false,
+ "total_hosts": 3,
+ "hosts_with_active_failures": 0,
+ "total_groups": 0,
+ "groups_with_active_failures": 0,
+ "has_inventory_sources": true,
+ "total_inventory_sources": 1,
+ "inventory_sources_with_failures": 0,
+ "organization_id": 3,
+ "kind": ""
+ },
+ "last_job": {
+ "id": 3,
+ "name": "Ping",
+ "description": "",
+ "finished": "2019-10-28T21:29:08.880572Z",
+ "status": "successful",
+ "failed": false,
+ "job_template_id": 9,
+ "job_template_name": "Ping"
+ },
+ "last_job_host_summary": {
+ "id": 1,
+ "failed": false
+ },
+ "user_capabilities": {
+ "edit": true,
+ "delete": true
+ },
+ "groups": {
+ "count": 0,
+ "results": []
+ },
+ "recent_jobs": [
+ {
+ "id": 3,
+ "name": "Ping",
+ "status": "successful",
+ "finished": "2019-10-28T21:29:08.880572Z",
+ "type": "job"
+ }
+ ]
+ },
+ "created": "2019-10-28T21:26:54.508081Z",
+ "modified": "2019-10-29T20:18:41.915796Z",
+ "name": "localhost",
+ "description": "localhost description",
+ "inventory": 3,
+ "enabled": true,
+ "instance_id": "",
+ "variables": "---\nansible_connection: local",
+ "has_active_failures": false,
+ "has_inventory_sources": false,
+ "last_job": 3,
+ "last_job_host_summary": 1,
+ "insights_system_id": null,
+ "ansible_facts_modified": null
+}
\ No newline at end of file
diff --git a/awx/ui_next/src/screens/Inventory/shared/data.hosts.json b/awx/ui_next/src/screens/Inventory/shared/data.hosts.json
new file mode 100644
index 0000000000..07c6ef7d9f
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/shared/data.hosts.json
@@ -0,0 +1,393 @@
+
+{
+ "count": 3,
+ "results": [
+ {
+ "id": 2,
+ "type": "host",
+ "url": "/api/v2/hosts/2/",
+ "related": {
+ "created_by": "/api/v2/users/10/",
+ "modified_by": "/api/v2/users/19/",
+ "variable_data": "/api/v2/hosts/2/variable_data/",
+ "groups": "/api/v2/hosts/2/groups/",
+ "all_groups": "/api/v2/hosts/2/all_groups/",
+ "job_events": "/api/v2/hosts/2/job_events/",
+ "job_host_summaries": "/api/v2/hosts/2/job_host_summaries/",
+ "activity_stream": "/api/v2/hosts/2/activity_stream/",
+ "inventory_sources": "/api/v2/hosts/2/inventory_sources/",
+ "smart_inventories": "/api/v2/hosts/2/smart_inventories/",
+ "ad_hoc_commands": "/api/v2/hosts/2/ad_hoc_commands/",
+ "ad_hoc_command_events": "/api/v2/hosts/2/ad_hoc_command_events/",
+ "insights": "/api/v2/hosts/2/insights/",
+ "ansible_facts": "/api/v2/hosts/2/ansible_facts/",
+ "inventory": "/api/v2/inventories/2/",
+ "last_job": "/api/v2/jobs/236/",
+ "last_job_host_summary": "/api/v2/job_host_summaries/2202/"
+ },
+ "summary_fields": {
+ "inventory": {
+ "id": 2,
+ "name": " Inventory 1 Org 0",
+ "description": "",
+ "has_active_failures": false,
+ "total_hosts": 33,
+ "hosts_with_active_failures": 0,
+ "total_groups": 4,
+ "has_inventory_sources": false,
+ "total_inventory_sources": 0,
+ "inventory_sources_with_failures": 0,
+ "organization_id": 2,
+ "kind": ""
+ },
+ "last_job": {
+ "id": 236,
+ "name": " Job Template 1 Project 0",
+ "description": "",
+ "finished": "2020-02-26T03:15:21.471439Z",
+ "status": "successful",
+ "failed": false,
+ "job_template_id": 18,
+ "job_template_name": " Job Template 1 Project 0"
+ },
+ "last_job_host_summary": {
+ "id": 2202,
+ "failed": false
+ },
+ "created_by": {
+ "id": 10,
+ "username": "user-3",
+ "first_name": "",
+ "last_name": ""
+ },
+ "modified_by": {
+ "id": 19,
+ "username": "all",
+ "first_name": "",
+ "last_name": ""
+ },
+ "user_capabilities": {
+ "edit": true,
+ "delete": true
+ },
+ "groups": {
+ "count": 2,
+ "results": [
+ {
+ "id": 1,
+ "name": " Group 1 Inventory 0"
+ },
+ {
+ "id": 2,
+ "name": " Group 2 Inventory 0"
+ }
+ ]
+ },
+ "recent_jobs": [
+ {
+ "id": 236,
+ "name": " Job Template 1 Project 0",
+ "status": "successful",
+ "finished": "2020-02-26T03:15:21.471439Z"
+ },
+ {
+ "id": 232,
+ "name": " Job Template 1 Project 0",
+ "status": "successful",
+ "finished": "2020-02-25T21:20:33.593789Z"
+ },
+ {
+ "id": 229,
+ "name": " Job Template 1 Project 0",
+ "status": "successful",
+ "finished": "2020-02-25T16:19:46.364134Z"
+ },
+ {
+ "id": 228,
+ "name": " Job Template 1 Project 0",
+ "status": "successful",
+ "finished": "2020-02-25T16:18:54.138363Z"
+ },
+ {
+ "id": 225,
+ "name": " Job Template 1 Project 0",
+ "status": "successful",
+ "finished": "2020-02-25T15:55:32.247652Z"
+ }
+ ]
+ },
+ "created": "2020-02-24T15:10:58.922179Z",
+ "modified": "2020-02-26T21:52:43.428530Z",
+ "name": ".host-000001.group-00000.dummy",
+ "description": "",
+ "inventory": 2,
+ "enabled": false,
+ "instance_id": "",
+ "variables": "",
+ "has_active_failures": false,
+ "has_inventory_sources": false,
+ "last_job": 236,
+ "last_job_host_summary": 2202,
+ "insights_system_id": null,
+ "ansible_facts_modified": null
+ },
+ {
+ "id": 3,
+ "type": "host",
+ "url": "/api/v2/hosts/3/",
+ "related": {
+ "created_by": "/api/v2/users/11/",
+ "modified_by": "/api/v2/users/1/",
+ "variable_data": "/api/v2/hosts/3/variable_data/",
+ "groups": "/api/v2/hosts/3/groups/",
+ "all_groups": "/api/v2/hosts/3/all_groups/",
+ "job_events": "/api/v2/hosts/3/job_events/",
+ "job_host_summaries": "/api/v2/hosts/3/job_host_summaries/",
+ "activity_stream": "/api/v2/hosts/3/activity_stream/",
+ "inventory_sources": "/api/v2/hosts/3/inventory_sources/",
+ "smart_inventories": "/api/v2/hosts/3/smart_inventories/",
+ "ad_hoc_commands": "/api/v2/hosts/3/ad_hoc_commands/",
+ "ad_hoc_command_events": "/api/v2/hosts/3/ad_hoc_command_events/",
+ "insights": "/api/v2/hosts/3/insights/",
+ "ansible_facts": "/api/v2/hosts/3/ansible_facts/",
+ "inventory": "/api/v2/inventories/2/",
+ "last_job": "/api/v2/jobs/236/",
+ "last_job_host_summary": "/api/v2/job_host_summaries/2195/"
+ },
+ "summary_fields": {
+ "inventory": {
+ "id": 2,
+ "name": " Inventory 1 Org 0",
+ "description": "",
+ "has_active_failures": false,
+ "total_hosts": 33,
+ "hosts_with_active_failures": 0,
+ "total_groups": 4,
+ "has_inventory_sources": false,
+ "total_inventory_sources": 0,
+ "inventory_sources_with_failures": 0,
+ "organization_id": 2,
+ "kind": ""
+ },
+ "last_job": {
+ "id": 236,
+ "name": " Job Template 1 Project 0",
+ "description": "",
+ "finished": "2020-02-26T03:15:21.471439Z",
+ "status": "successful",
+ "failed": false,
+ "job_template_id": 18,
+ "job_template_name": " Job Template 1 Project 0"
+ },
+ "last_job_host_summary": {
+ "id": 2195,
+ "failed": false
+ },
+ "created_by": {
+ "id": 11,
+ "username": "user-4",
+ "first_name": "",
+ "last_name": ""
+ },
+ "modified_by": {
+ "id": 1,
+ "username": "admin",
+ "first_name": "",
+ "last_name": ""
+ },
+ "user_capabilities": {
+ "edit": true,
+ "delete": true
+ },
+ "groups": {
+ "count": 2,
+ "results": [
+ {
+ "id": 1,
+ "name": " Group 1 Inventory 0"
+ },
+ {
+ "id": 2,
+ "name": " Group 2 Inventory 0"
+ }
+ ]
+ },
+ "recent_jobs": [
+ {
+ "id": 236,
+ "name": " Job Template 1 Project 0",
+ "status": "successful",
+ "finished": "2020-02-26T03:15:21.471439Z"
+ },
+ {
+ "id": 232,
+ "name": " Job Template 1 Project 0",
+ "status": "successful",
+ "finished": "2020-02-25T21:20:33.593789Z"
+ },
+ {
+ "id": 229,
+ "name": " Job Template 1 Project 0",
+ "status": "successful",
+ "finished": "2020-02-25T16:19:46.364134Z"
+ },
+ {
+ "id": 228,
+ "name": " Job Template 1 Project 0",
+ "status": "successful",
+ "finished": "2020-02-25T16:18:54.138363Z"
+ },
+ {
+ "id": 225,
+ "name": " Job Template 1 Project 0",
+ "status": "successful",
+ "finished": "2020-02-25T15:55:32.247652Z"
+ }
+ ]
+ },
+ "created": "2020-02-24T15:10:58.945113Z",
+ "modified": "2020-02-27T03:43:43.635871Z",
+ "name": ".host-000002.group-00000.dummy",
+ "description": "",
+ "inventory": 2,
+ "enabled": false,
+ "instance_id": "",
+ "variables": "",
+ "has_active_failures": false,
+ "has_inventory_sources": false,
+ "last_job": 236,
+ "last_job_host_summary": 2195,
+ "insights_system_id": null,
+ "ansible_facts_modified": null
+ },
+ {
+ "id": 4,
+ "type": "host",
+ "url": "/api/v2/hosts/4/",
+ "related": {
+ "created_by": "/api/v2/users/12/",
+ "modified_by": "/api/v2/users/1/",
+ "variable_data": "/api/v2/hosts/4/variable_data/",
+ "groups": "/api/v2/hosts/4/groups/",
+ "all_groups": "/api/v2/hosts/4/all_groups/",
+ "job_events": "/api/v2/hosts/4/job_events/",
+ "job_host_summaries": "/api/v2/hosts/4/job_host_summaries/",
+ "activity_stream": "/api/v2/hosts/4/activity_stream/",
+ "inventory_sources": "/api/v2/hosts/4/inventory_sources/",
+ "smart_inventories": "/api/v2/hosts/4/smart_inventories/",
+ "ad_hoc_commands": "/api/v2/hosts/4/ad_hoc_commands/",
+ "ad_hoc_command_events": "/api/v2/hosts/4/ad_hoc_command_events/",
+ "insights": "/api/v2/hosts/4/insights/",
+ "ansible_facts": "/api/v2/hosts/4/ansible_facts/",
+ "inventory": "/api/v2/inventories/2/",
+ "last_job": "/api/v2/jobs/236/",
+ "last_job_host_summary": "/api/v2/job_host_summaries/2192/"
+ },
+ "summary_fields": {
+ "inventory": {
+ "id": 2,
+ "name": " Inventory 1 Org 0",
+ "description": "",
+ "has_active_failures": false,
+ "total_hosts": 33,
+ "hosts_with_active_failures": 0,
+ "total_groups": 4,
+ "has_inventory_sources": false,
+ "total_inventory_sources": 0,
+ "inventory_sources_with_failures": 0,
+ "organization_id": 2,
+ "kind": ""
+ },
+ "last_job": {
+ "id": 236,
+ "name": " Job Template 1 Project 0",
+ "description": "",
+ "finished": "2020-02-26T03:15:21.471439Z",
+ "status": "successful",
+ "failed": false,
+ "job_template_id": 18,
+ "job_template_name": " Job Template 1 Project 0"
+ },
+ "last_job_host_summary": {
+ "id": 2192,
+ "failed": false
+ },
+ "created_by": {
+ "id": 12,
+ "username": "user-5",
+ "first_name": "",
+ "last_name": ""
+ },
+ "modified_by": {
+ "id": 1,
+ "username": "admin",
+ "first_name": "",
+ "last_name": ""
+ },
+ "user_capabilities": {
+ "edit": true,
+ "delete": true
+ },
+ "groups": {
+ "count": 2,
+ "results": [
+ {
+ "id": 1,
+ "name": " Group 1 Inventory 0"
+ },
+ {
+ "id": 2,
+ "name": " Group 2 Inventory 0"
+ }
+ ]
+ },
+ "recent_jobs": [
+ {
+ "id": 236,
+ "name": " Job Template 1 Project 0",
+ "status": "successful",
+ "finished": "2020-02-26T03:15:21.471439Z"
+ },
+ {
+ "id": 232,
+ "name": " Job Template 1 Project 0",
+ "status": "successful",
+ "finished": "2020-02-25T21:20:33.593789Z"
+ },
+ {
+ "id": 229,
+ "name": " Job Template 1 Project 0",
+ "status": "successful",
+ "finished": "2020-02-25T16:19:46.364134Z"
+ },
+ {
+ "id": 228,
+ "name": " Job Template 1 Project 0",
+ "status": "successful",
+ "finished": "2020-02-25T16:18:54.138363Z"
+ },
+ {
+ "id": 225,
+ "name": " Job Template 1 Project 0",
+ "status": "successful",
+ "finished": "2020-02-25T15:55:32.247652Z"
+ }
+ ]
+ },
+ "created": "2020-02-24T15:10:58.962312Z",
+ "modified": "2020-02-27T03:43:45.528882Z",
+ "name": ".host-000003.group-00000.dummy",
+ "description": "",
+ "inventory": 2,
+ "enabled": false,
+ "instance_id": "",
+ "variables": "",
+ "has_active_failures": false,
+ "has_inventory_sources": false,
+ "last_job": 236,
+ "last_job_host_summary": 2192,
+ "insights_system_id": null,
+ "ansible_facts_modified": null
+ }
+ ]
+}
diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx
index 3634c73b89..01f09e6c11 100644
--- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx
+++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx
@@ -164,7 +164,15 @@ function JobDetail({ job, i18n }) {
{inventory.name}
+
+ {inventory.name}
+
}
/>
)}
diff --git a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationListItem.jsx b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationListItem.jsx
index f2b1a4f8ee..35145e3998 100644
--- a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationListItem.jsx
+++ b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationListItem.jsx
@@ -5,7 +5,7 @@ import { t } from '@lingui/macro';
import {
Badge as PFBadge,
Button,
- DataListAction,
+ DataListAction as _DataListAction,
DataListCell,
DataListCheck,
DataListItem,
@@ -31,6 +31,13 @@ const ListGroup = styled.span`
}
`;
+const DataListAction = styled(_DataListAction)`
+ align-items: center;
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: 40px;
+`;
+
function OrganizationListItem({
organization,
isSelected,
@@ -82,7 +89,7 @@ function OrganizationListItem({
aria-labelledby={labelId}
id={labelId}
>
- {organization.summary_fields.user_capabilities.edit && (
+ {organization.summary_fields.user_capabilities.edit ? (
+ ) : (
+ ''
)}
diff --git a/awx/ui_next/src/screens/Project/Project.jsx b/awx/ui_next/src/screens/Project/Project.jsx
index 9e401fde76..0016acdc39 100644
--- a/awx/ui_next/src/screens/Project/Project.jsx
+++ b/awx/ui_next/src/screens/Project/Project.jsx
@@ -9,7 +9,7 @@ import RoutedTabs from '@components/RoutedTabs';
import ContentError from '@components/ContentError';
import NotificationList from '@components/NotificationList';
import { ResourceAccessList } from '@components/ResourceAccessList';
-import ScheduleList from '@components/ScheduleList';
+import { Schedules } from '@components/Schedule';
import ProjectDetail from './ProjectDetail';
import ProjectEdit from './ProjectEdit';
import ProjectJobTemplatesList from './ProjectJobTemplatesList';
@@ -31,6 +31,7 @@ class Project extends Component {
this.loadProject = this.loadProject.bind(this);
this.loadProjectAndRoles = this.loadProjectAndRoles.bind(this);
this.loadSchedules = this.loadSchedules.bind(this);
+ this.loadScheduleOptions = this.loadScheduleOptions.bind(this);
}
async componentDidMount() {
@@ -104,13 +105,18 @@ class Project extends Component {
}
}
+ loadScheduleOptions() {
+ const { project } = this.state;
+ return ProjectsAPI.readScheduleOptions(project.id);
+ }
+
loadSchedules(params) {
const { project } = this.state;
- return ProjectsAPI.readScheduleList(project.id, params);
+ return ProjectsAPI.readSchedules(project.id, params);
}
render() {
- const { location, match, me, i18n } = this.props;
+ const { location, match, me, i18n, setBreadcrumb } = this.props;
const {
project,
@@ -169,7 +175,10 @@ class Project extends Component {
cardHeader = null;
}
- if (location.pathname.endsWith('edit')) {
+ if (
+ location.pathname.endsWith('edit') ||
+ location.pathname.includes('schedules/')
+ ) {
cardHeader = null;
}
@@ -241,7 +250,12 @@ class Project extends Component {
(
-
+
)}
/>
)}
diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx
index b540be821a..92e337e892 100644
--- a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx
@@ -126,7 +126,7 @@ class ProjectListItem extends React.Component {
aria-labelledby={labelId}
id={labelId}
>
- {project.summary_fields.user_capabilities.start && (
+ {project.summary_fields.user_capabilities.start ? (
{handleSync => (
@@ -140,8 +140,10 @@ class ProjectListItem extends React.Component {
)}
+ ) : (
+ ''
)}
- {project.summary_fields.user_capabilities.edit && (
+ {project.summary_fields.user_capabilities.edit ? (
+ ) : (
+ ''
)}
diff --git a/awx/ui_next/src/screens/Schedule/Schedules.jsx b/awx/ui_next/src/screens/Schedule/AllSchedules.jsx
similarity index 64%
rename from awx/ui_next/src/screens/Schedule/Schedules.jsx
rename to awx/ui_next/src/screens/Schedule/AllSchedules.jsx
index 0f7c9b0ee0..1f7ffa3188 100644
--- a/awx/ui_next/src/screens/Schedule/Schedules.jsx
+++ b/awx/ui_next/src/screens/Schedule/AllSchedules.jsx
@@ -4,11 +4,15 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import Breadcrumbs from '@components/Breadcrumbs';
-import ScheduleList from '@components/ScheduleList';
+import { ScheduleList } from '@components/Schedule';
import { SchedulesAPI } from '@api';
import { PageSection, Card } from '@patternfly/react-core';
-function Schedules({ i18n }) {
+function AllSchedules({ i18n }) {
+ const loadScheduleOptions = () => {
+ return SchedulesAPI.readOptions();
+ };
+
const loadSchedules = params => {
return SchedulesAPI.read(params);
};
@@ -24,7 +28,11 @@ function Schedules({ i18n }) {
-
+
@@ -33,4 +41,4 @@ function Schedules({ i18n }) {
);
}
-export default withI18n()(Schedules);
+export default withI18n()(AllSchedules);
diff --git a/awx/ui_next/src/screens/Schedule/Schedules.test.jsx b/awx/ui_next/src/screens/Schedule/AllSchedules.test.jsx
similarity index 79%
rename from awx/ui_next/src/screens/Schedule/Schedules.test.jsx
rename to awx/ui_next/src/screens/Schedule/AllSchedules.test.jsx
index ebdc67a046..50e4b76f66 100644
--- a/awx/ui_next/src/screens/Schedule/Schedules.test.jsx
+++ b/awx/ui_next/src/screens/Schedule/AllSchedules.test.jsx
@@ -1,9 +1,9 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { createMemoryHistory } from 'history';
-import Schedules from './Schedules';
+import AllSchedules from './AllSchedules';
-describe('', () => {
+describe('', () => {
let wrapper;
afterEach(() => {
@@ -11,7 +11,7 @@ describe('', () => {
});
test('initially renders succesfully', () => {
- wrapper = mountWithContexts();
+ wrapper = mountWithContexts();
});
test('should display schedule list breadcrumb heading', () => {
@@ -19,7 +19,7 @@ describe('', () => {
initialEntries: ['/schedules'],
});
- wrapper = mountWithContexts(, {
+ wrapper = mountWithContexts(, {
context: {
router: {
history,
diff --git a/awx/ui_next/src/screens/Schedule/index.js b/awx/ui_next/src/screens/Schedule/index.js
index 64f2dedc84..3e38a47a40 100644
--- a/awx/ui_next/src/screens/Schedule/index.js
+++ b/awx/ui_next/src/screens/Schedule/index.js
@@ -1 +1 @@
-export { default } from './Schedules';
+export { default } from './AllSchedules';
diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx
index 0b1e66e9d3..b915117d2e 100644
--- a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx
+++ b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx
@@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Button,
- DataListAction,
+ DataListAction as _DataListAction,
DataListCell,
DataListCheck,
DataListItem,
@@ -12,11 +12,19 @@ import {
DataListItemRow,
Tooltip,
} from '@patternfly/react-core';
+import styled from 'styled-components';
import { Link } from 'react-router-dom';
import { PencilAltIcon } from '@patternfly/react-icons';
import { Team } from '@types';
+const DataListAction = styled(_DataListAction)`
+ align-items: center;
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: 40px;
+`;
+
class TeamListItem extends React.Component {
static propTypes = {
team: Team.isRequired,
@@ -64,7 +72,7 @@ class TeamListItem extends React.Component {
aria-labelledby={labelId}
id={labelId}
>
- {team.summary_fields.user_capabilities.edit && (
+ {team.summary_fields.user_capabilities.edit ? (
+ ) : (
+ ''
)}
diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx
index 69a55550f1..5863abe8ea 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useHistory } from 'react-router-dom';
-import { Card } from '@patternfly/react-core';
+import { Card, PageSection } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import JobTemplateForm from '../shared/JobTemplateForm';
import { JobTemplatesAPI } from '@api';
@@ -61,15 +61,17 @@ function JobTemplateAdd() {
}
return (
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/awx/ui_next/src/screens/Template/Template.jsx b/awx/ui_next/src/screens/Template/Template.jsx
index 84839c1a3f..7cdd8ce032 100644
--- a/awx/ui_next/src/screens/Template/Template.jsx
+++ b/awx/ui_next/src/screens/Template/Template.jsx
@@ -10,11 +10,12 @@ import ContentError from '@components/ContentError';
import JobList from '@components/JobList';
import NotificationList from '@components/NotificationList';
import RoutedTabs from '@components/RoutedTabs';
-import ScheduleList from '@components/ScheduleList';
+import { Schedules } from '@components/Schedule';
import { ResourceAccessList } from '@components/ResourceAccessList';
import JobTemplateDetail from './JobTemplateDetail';
import JobTemplateEdit from './JobTemplateEdit';
import { JobTemplatesAPI, OrganizationsAPI } from '@api';
+import SurveyList from './shared/SurveyList';
class Template extends Component {
constructor(props) {
@@ -29,6 +30,7 @@ class Template extends Component {
this.loadTemplate = this.loadTemplate.bind(this);
this.loadTemplateAndRoles = this.loadTemplateAndRoles.bind(this);
this.loadSchedules = this.loadSchedules.bind(this);
+ this.loadScheduleOptions = this.loadScheduleOptions.bind(this);
}
async componentDidMount() {
@@ -83,13 +85,18 @@ class Template extends Component {
}
}
+ loadScheduleOptions() {
+ const { template } = this.state;
+ return JobTemplatesAPI.readScheduleOptions(template.id);
+ }
+
loadSchedules(params) {
const { template } = this.state;
- return JobTemplatesAPI.readScheduleList(template.id, params);
+ return JobTemplatesAPI.readSchedules(template.id, params);
}
render() {
- const { i18n, location, match, me } = this.props;
+ const { i18n, location, match, me, setBreadcrumb } = this.props;
const {
contentError,
hasContentLoading,
@@ -111,17 +118,6 @@ class Template extends Component {
});
}
- tabsArray.push(
- {
- name: i18n._(t`Completed Jobs`),
- link: `${match.url}/completed_jobs`,
- },
- {
- name: i18n._(t`Survey`),
- link: '/home',
- }
- );
-
if (template) {
tabsArray.push({
name: i18n._(t`Schedules`),
@@ -129,6 +125,21 @@ class Template extends Component {
});
}
+ tabsArray.push(
+ {
+ name: i18n._(t`Schedules`),
+ link: `${match.url}/schedules`,
+ },
+ {
+ name: i18n._(t`Completed Jobs`),
+ link: `${match.url}/completed_jobs`,
+ },
+ {
+ name: i18n._(t`Survey`),
+ link: `${match.url}/survey`,
+ }
+ );
+
tabsArray.forEach((tab, n) => {
tab.id = n;
});
@@ -142,7 +153,10 @@ class Template extends Component {
);
- if (location.pathname.endsWith('edit')) {
+ if (
+ location.pathname.endsWith('edit') ||
+ location.pathname.includes('schedules/')
+ ) {
cardHeader = null;
}
@@ -164,87 +178,103 @@ class Template extends Component {
}
return (
-
- {cardHeader}
-
-
- {template && (
- (
-
- )}
+
+
+ {cardHeader}
+
+
- )}
- {template && (
+ {template && (
+ (
+
+ )}
+ />
+ )}
+ {template && (
+ }
+ />
+ )}
+ {template && (
+ (
+
+ )}
+ />
+ )}
+ {template && (
+ (
+
+ )}
+ />
+ )}
+ {canSeeNotificationsTab && (
+ (
+
+ )}
+ />
+ )}
+ {template?.id && (
+
+
+
+ )}
+ {template && (
+ }
+ />
+ )}
}
+ key="not-found"
+ path="*"
+ render={() =>
+ !hasContentLoading && (
+
+ {match.params.id && (
+
+ {i18n._(`View Template Details`)}
+
+ )}
+
+ )
+ }
/>
- )}
- {template && (
- (
-
- )}
- />
- )}
- {canSeeNotificationsTab && (
- (
-
- )}
- />
- )}
- {template?.id && (
-
-
-
- )}
- {template && (
- }
- />
- )}
-
- !hasContentLoading && (
-
- {match.params.id && (
-
- {i18n._(`View Template Details`)}
-
- )}
-
- )
- }
- />
-
-
+
+
+
);
}
}
diff --git a/awx/ui_next/src/screens/Template/Template.test.jsx b/awx/ui_next/src/screens/Template/Template.test.jsx
index c4f0a36af5..2f3c56cc58 100644
--- a/awx/ui_next/src/screens/Template/Template.test.jsx
+++ b/awx/ui_next/src/screens/Template/Template.test.jsx
@@ -58,10 +58,11 @@ describe('', () => {
const wrapper = mountWithContexts(
{}} me={mockMe} />
);
+
const tabs = await waitForElement(
wrapper,
'.pf-c-tabs__item',
- el => el.length === 6
+ el => el.length === 7
);
expect(tabs.at(2).text()).toEqual('Notifications');
done();
diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx
index 717b1b5f89..913fcfb648 100644
--- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx
+++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx
@@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
-import { Card } from '@patternfly/react-core';
+import { Card, PageSection } from '@patternfly/react-core';
import {
JobTemplatesAPI,
@@ -141,7 +141,7 @@ function TemplateList({ i18n }) {
);
return (
- <>
+
- >
+
);
}
diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx
index b13eb92298..d412e25baf 100644
--- a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx
+++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx
@@ -98,7 +98,7 @@ function TemplateListItem({ i18n, template, isSelected, onSelect, detailUrl }) {
)}
- {template.summary_fields.user_capabilities.edit && (
+ {template.summary_fields.user_capabilities.edit ? (
+ ) : (
+ ''
)}
diff --git a/awx/ui_next/src/screens/Template/Templates.jsx b/awx/ui_next/src/screens/Template/Templates.jsx
index c8eb2f243e..b8c5252aca 100644
--- a/awx/ui_next/src/screens/Template/Templates.jsx
+++ b/awx/ui_next/src/screens/Template/Templates.jsx
@@ -2,7 +2,6 @@ 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';
@@ -10,6 +9,7 @@ import { TemplateList } from './TemplateList';
import Template from './Template';
import WorkflowJobTemplate from './WorkflowJobTemplate';
import JobTemplateAdd from './JobTemplateAdd';
+import WorkflowJobTemplateAdd from './WorkflowJobTemplateAdd';
class Templates extends Component {
constructor(props) {
@@ -20,11 +20,14 @@ class Templates extends Component {
breadcrumbConfig: {
'/templates': i18n._(t`Templates`),
'/templates/job_template/add': i18n._(t`Create New Job Template`),
+ '/templates/workflow_job_template/add': i18n._(
+ t`Create New Workflow Template`
+ ),
},
};
}
- setBreadCrumbConfig = template => {
+ setBreadCrumbConfig = (template, schedule) => {
const { i18n } = this.props;
if (!template) {
return;
@@ -32,6 +35,9 @@ class Templates extends Component {
const breadcrumbConfig = {
'/templates': i18n._(t`Templates`),
'/templates/job_template/add': i18n._(t`Create New Job Template`),
+ '/templates/workflow_job_template/add': i18n._(
+ t`Create New Workflow Template`
+ ),
[`/templates/${template.type}/${template.id}`]: `${template.name}`,
[`/templates/${template.type}/${template.id}/details`]: i18n._(
t`Details`
@@ -46,6 +52,14 @@ class Templates extends Component {
[`/templates/${template.type}/${template.id}/completed_jobs`]: i18n._(
t`Completed Jobs`
),
+ [`/templates/${template.type}/${template.id}/survey`]: i18n._(t`Survey`),
+ [`/templates/${template.type}/${template.id}/schedules`]: i18n._(
+ t`Schedules`
+ ),
+ [`/templates/${template.type}/${template.id}/schedules/${schedule &&
+ schedule.id}`]: `${schedule && schedule.name}`,
+ [`/templates/${template.type}/${template.id}/schedules/${schedule &&
+ schedule.id}/details`]: i18n._(t`Schedule Details`),
};
this.setState({ breadcrumbConfig });
};
@@ -56,47 +70,48 @@ class Templates extends Component {
return (
<>
-
-
- }
- />
- (
-
- {({ me }) => (
-
- )}
-
- )}
- />
- (
-
- {({ me }) => (
-
- )}
-
- )}
- />
- } />
-
-
+
+ }
+ />
+ }
+ />
+ (
+
+ {({ me }) => (
+
+ )}
+
+ )}
+ />
+ (
+
+ {({ me }) => (
+
+ )}
+
+ )}
+ />
+ } />
+
>
);
}
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx
index bdb9ca8ad6..a1a29752b0 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx
@@ -10,9 +10,11 @@ import ContentError from '@components/ContentError';
import FullPage from '@components/FullPage';
import JobList from '@components/JobList';
import RoutedTabs from '@components/RoutedTabs';
-import ScheduleList from '@components/ScheduleList';
+import { Schedules } from '@components/Schedule';
+import ContentLoading from '@components/ContentLoading';
import { WorkflowJobTemplatesAPI, CredentialsAPI } from '@api';
import WorkflowJobTemplateDetail from './WorkflowJobTemplateDetail';
+import WorkflowJobTemplateEdit from './WorkflowJobTemplateEdit';
import { Visualizer } from './WorkflowJobTemplateVisualizer';
class WorkflowJobTemplate extends Component {
@@ -23,9 +25,11 @@ class WorkflowJobTemplate extends Component {
contentError: null,
hasContentLoading: true,
template: null,
+ webhook_key: null,
};
this.loadTemplate = this.loadTemplate.bind(this);
this.loadSchedules = this.loadSchedules.bind(this);
+ this.loadScheduleOptions = this.loadScheduleOptions.bind(this);
}
async componentDidMount() {
@@ -43,26 +47,27 @@ class WorkflowJobTemplate extends Component {
const { setBreadcrumb, match } = this.props;
const { id } = match.params;
- this.setState({ contentError: null, hasContentLoading: true });
+ this.setState({ contentError: null });
try {
const { data } = await WorkflowJobTemplatesAPI.readDetail(id);
if (data?.related?.webhook_key) {
const {
data: { webhook_key },
} = await WorkflowJobTemplatesAPI.readWebhookKey(id);
- this.setState({ webHookKey: webhook_key });
+ this.setState({ webhook_key });
}
if (data?.summary_fields?.webhook_credential) {
const {
data: {
- summary_fields: { credential_type: name },
+ summary_fields: {
+ credential_type: { name },
+ },
},
} = await CredentialsAPI.readDetail(
data.summary_fields.webhook_credential.id
);
data.summary_fields.webhook_credential.kind = name;
}
-
this.setState({ template: data });
setBreadcrumb(data);
} catch (err) {
@@ -72,18 +77,23 @@ class WorkflowJobTemplate extends Component {
}
}
+ loadScheduleOptions() {
+ const { template } = this.state;
+ return WorkflowJobTemplatesAPI.readScheduleOptions(template.id);
+ }
+
loadSchedules(params) {
const { template } = this.state;
- return WorkflowJobTemplatesAPI.readScheduleList(template.id, params);
+ return WorkflowJobTemplatesAPI.readSchedules(template.id, params);
}
render() {
- const { i18n, location, match } = this.props;
+ const { i18n, location, match, setBreadcrumb } = this.props;
const {
contentError,
hasContentLoading,
template,
- webHookKey,
+ webhook_key,
} = this.state;
const tabsArray = [
@@ -103,20 +113,17 @@ class WorkflowJobTemplate extends Component {
tab.id = n;
});
- let cardHeader = hasContentLoading ? null : (
-
-
-
-
-
-
- );
-
- if (location.pathname.endsWith('edit')) {
- cardHeader = null;
+ if (hasContentLoading) {
+ return (
+
+
+
+
+
+ );
}
- if (!hasContentLoading && contentError) {
+ if (contentError) {
return (
@@ -133,60 +140,91 @@ class WorkflowJobTemplate extends Component {
);
}
+ const cardHeader = (
+
+
+
+
+
+
+ );
+
return (
-
- {cardHeader}
-
-
- {template && (
- (
-
- )}
+
+
+ {location.pathname.endsWith('edit') ||
+ location.pathname.includes('schedules/')
+ ? null
+ : cardHeader}
+
+
- )}
- {template && (
- (
-
-
-
-
-
- )}
- />
- )}
- {template?.id && (
-
- (
+
+ )}
/>
-
- )}
- {template && (
+ )}
+ {template && (
+ (
+
+ )}
+ />
+ )}
+ {template && (
+ (
+
+
+
+
+
+ )}
+ />
+ )}
+ {template?.id && (
+
+
+
+ )}
+ {template?.id && (
+ (
+
+ )}
+ />
+ )}
}
- />
- )}
-
- !hasContentLoading && (
+ key="not-found"
+ path="*"
+ render={() => (
{match.params.id && (
)}
- )
- }
- />
-
-
+ )}
+ />
+
+
+
);
}
}
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.jsx
new file mode 100644
index 0000000000..c1efe45572
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.jsx
@@ -0,0 +1,63 @@
+import React, { useState } from 'react';
+import { useHistory } from 'react-router-dom';
+
+import { Card, PageSection } from '@patternfly/react-core';
+import { CardBody } from '@components/Card';
+
+import { WorkflowJobTemplatesAPI, OrganizationsAPI } from '@api';
+import WorkflowJobTemplateForm from '../shared/WorkflowJobTemplateForm';
+
+function WorkflowJobTemplateAdd() {
+ const history = useHistory();
+ const [formSubmitError, setFormSubmitError] = useState(null);
+
+ const handleSubmit = async values => {
+ const { labels, organizationId, ...remainingValues } = values;
+ try {
+ const {
+ data: { id },
+ } = await WorkflowJobTemplatesAPI.create(remainingValues);
+ await Promise.all(await submitLabels(id, labels, organizationId));
+ history.push(`/templates/workflow_job_template/${id}/details`);
+ } catch (err) {
+ setFormSubmitError(err);
+ }
+ };
+
+ const submitLabels = async (templateId, labels = [], organizationId) => {
+ if (!organizationId) {
+ try {
+ const {
+ data: { results },
+ } = await OrganizationsAPI.read();
+ organizationId = results[0].id;
+ } catch (err) {
+ throw err;
+ }
+ }
+ const associatePromises = labels.map(label =>
+ WorkflowJobTemplatesAPI.associateLabel(templateId, label, organizationId)
+ );
+ return [...associatePromises];
+ };
+
+ const handleCancel = () => {
+ history.push(`/templates`);
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default WorkflowJobTemplateAdd;
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.test.jsx
new file mode 100644
index 0000000000..7a4c11831c
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.test.jsx
@@ -0,0 +1,130 @@
+import React from 'react';
+import { Route } from 'react-router-dom';
+import { act } from 'react-dom/test-utils';
+import { WorkflowJobTemplatesAPI, OrganizationsAPI, LabelsAPI } from '@api';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { createMemoryHistory } from 'history';
+
+import WorkflowJobTemplateAdd from './WorkflowJobTemplateAdd';
+
+jest.mock('@api/models/WorkflowJobTemplates');
+jest.mock('@api/models/Organizations');
+jest.mock('@api/models/Labels');
+jest.mock('@api/models/Inventories');
+
+describe('', () => {
+ let wrapper;
+ let history;
+ beforeEach(async () => {
+ WorkflowJobTemplatesAPI.create.mockResolvedValue({ data: { id: 1 } });
+ OrganizationsAPI.read.mockResolvedValue({ results: [{ id: 1 }] });
+ LabelsAPI.read.mockResolvedValue({
+ data: {
+ results: [
+ { name: 'Label 1', id: 1 },
+ { name: 'Label 2', id: 2 },
+ { name: 'Label 3', id: 3 },
+ ],
+ },
+ });
+
+ await act(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/templates/workflow_job_template/add'],
+ });
+ await act(async () => {
+ wrapper = await mountWithContexts(
+ }
+ />,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ },
+ },
+ },
+ }
+ );
+ });
+ });
+ });
+ afterEach(async () => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ test('initially renders successfully', async () => {
+ expect(wrapper.length).toBe(1);
+ });
+
+ test('calls workflowJobTemplatesAPI with correct information on submit', async () => {
+ await act(async () => {
+ await wrapper.find('WorkflowJobTemplateForm').invoke('handleSubmit')({
+ name: 'Alex',
+ labels: [{ name: 'Foo', id: 1 }, { name: 'bar', id: 2 }],
+ organizationId: 1,
+ });
+ });
+ expect(WorkflowJobTemplatesAPI.create).toHaveBeenCalledWith({
+ name: 'Alex',
+ });
+ expect(WorkflowJobTemplatesAPI.associateLabel).toHaveBeenCalledTimes(2);
+ });
+
+ test('handleCancel navigates the user to the /templates', async () => {
+ await act(async () => {
+ await wrapper.find('WorkflowJobTemplateForm').invoke('handleCancel')();
+ });
+ expect(history.location.pathname).toBe('/templates');
+ });
+
+ test('throwing error renders FormSubmitError component', async () => {
+ const error = {
+ response: {
+ config: {
+ method: 'post',
+ url: '/api/v2/workflow_job_templates/',
+ },
+ data: { detail: 'An error occurred' },
+ },
+ };
+
+ WorkflowJobTemplatesAPI.create.mockRejectedValue(error);
+ await act(async () => {
+ wrapper.find('WorkflowJobTemplateForm').invoke('handleSubmit')({
+ name: 'Foo',
+ });
+ });
+ expect(WorkflowJobTemplatesAPI.create).toHaveBeenCalled();
+ wrapper.update();
+ expect(wrapper.find('WorkflowJobTemplateForm').prop('submitError')).toEqual(
+ error
+ );
+ });
+
+ test('throwing error prevents navigation away from form', async () => {
+ OrganizationsAPI.read.mockRejectedValue({
+ response: {
+ config: {
+ method: 'get',
+ url: '/api/v2/organizations/',
+ },
+ data: 'An error occurred',
+ },
+ });
+ WorkflowJobTemplatesAPI.update.mockResolvedValue();
+
+ await act(async () => {
+ await wrapper.find('Button[aria-label="Save"]').simulate('click');
+ });
+ expect(wrapper.find('WorkflowJobTemplateForm').length).toBe(1);
+ expect(OrganizationsAPI.read).toBeCalled();
+ expect(history.location.pathname).toBe(
+ '/templates/workflow_job_template/add'
+ );
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/index.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/index.js
new file mode 100644
index 0000000000..51111ce0fb
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/index.js
@@ -0,0 +1 @@
+export { default } from './WorkflowJobTemplateAdd';
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx
index f97ed439fe..d528013def 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx
@@ -25,7 +25,7 @@ import LaunchButton from '@components/LaunchButton';
import Sparkline from '@components/Sparkline';
import { toTitleCase } from '@util/strings';
-function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
+function WorkflowJobTemplateDetail({ template, i18n, webhook_key }) {
const {
id,
ask_inventory_on_launch,
@@ -39,12 +39,15 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
related,
webhook_credential,
} = template;
+
const urlOrigin = window.location.origin;
const history = useHistory();
+
const [deletionError, setDeletionError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(false);
+
const renderOptionsField =
- template.allow_simultaneous || template.webhook_servicee;
+ template.allow_simultaneous || template.webhook_service;
const renderOptions = (
@@ -55,7 +58,7 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
)}
{template.webhook_service && (
- {i18n._(t`- Webhooks`)}
+ {i18n._(t`- Enable Webhook`)}
)}
@@ -75,6 +78,7 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
}
setHasContentLoading(false);
};
+
const inventoryValue = (kind, inventoryId) => {
const inventorykind = kind === 'smart' ? 'smart_inventory' : 'inventory';
@@ -91,6 +95,7 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
);
};
+
const canLaunch = summary_fields?.user_capabilities?.start;
const recentPlaybookJobs = summary_fields.recent_jobs.map(job => ({
...job,
@@ -143,7 +148,7 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
value={`${urlOrigin}${template.related.webhook_receiver}`}
/>
)}
-
+
{webhook_credential && (
', () => {
},
webhook_service: 'Github',
};
+
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/templates/workflow_job_template/1/details'],
@@ -51,7 +52,7 @@ describe('', () => {
component={() => (
{}}
/>
@@ -75,12 +76,15 @@ describe('', () => {
);
});
});
+
afterEach(() => {
wrapper.unmount();
});
+
test('renders successfully', () => {
expect(wrapper.find(WorkflowJobTemplateDetail).length).toBe(1);
});
+
test('expect detail fields to render properly', () => {
const renderedValues = [
{
@@ -147,6 +151,7 @@ describe('', () => {
renderedValues.map(value => assertValue(value));
});
+
test('link out resource have the correct url', () => {
const inventory = wrapper.find('Detail[label="Inventory"]').find('Link');
const organization = wrapper
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.jsx
new file mode 100644
index 0000000000..b958ff9ff4
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.jsx
@@ -0,0 +1,69 @@
+import React, { useState } from 'react';
+import { useHistory } from 'react-router-dom';
+
+import { CardBody } from '@components/Card';
+import { getAddedAndRemoved } from '@util/lists';
+import { WorkflowJobTemplatesAPI, OrganizationsAPI } from '@api';
+import { WorkflowJobTemplateForm } from '../shared';
+
+function WorkflowJobTemplateEdit({ template, webhook_key }) {
+ const history = useHistory();
+ const [formSubmitError, setFormSubmitError] = useState(null);
+
+ const handleSubmit = async values => {
+ const { labels, ...remainingValues } = values;
+ try {
+ await Promise.all(
+ await submitLabels(labels, values.organization, template.organization)
+ );
+ await WorkflowJobTemplatesAPI.update(template.id, remainingValues);
+ history.push(`/templates/workflow_job_template/${template.id}/details`);
+ } catch (err) {
+ setFormSubmitError(err);
+ }
+ };
+
+ const submitLabels = async (labels = [], formOrgId, templateOrgId) => {
+ const { added, removed } = getAddedAndRemoved(
+ template.summary_fields.labels.results,
+ labels
+ );
+ let orgId = formOrgId || templateOrgId;
+ if (!orgId) {
+ try {
+ const {
+ data: { results },
+ } = await OrganizationsAPI.read();
+ orgId = results[0].id;
+ } catch (err) {
+ throw err;
+ }
+ }
+
+ const disassociationPromises = await removed.map(label =>
+ WorkflowJobTemplatesAPI.disassociateLabel(template.id, label)
+ );
+ const associationPromises = await added.map(label =>
+ WorkflowJobTemplatesAPI.associateLabel(template.id, label, orgId)
+ );
+ const results = [...disassociationPromises, ...associationPromises];
+ return results;
+ };
+
+ const handleCancel = () => {
+ history.push(`/templates/workflow_job_template/${template.id}/details`);
+ };
+
+ return (
+
+
+
+ );
+}
+export default WorkflowJobTemplateEdit;
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.test.jsx
new file mode 100644
index 0000000000..49475d71f8
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.test.jsx
@@ -0,0 +1,188 @@
+import React from 'react';
+import { Route } from 'react-router-dom';
+import { act } from 'react-dom/test-utils';
+import { WorkflowJobTemplatesAPI, OrganizationsAPI, LabelsAPI } from '@api';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { createMemoryHistory } from 'history';
+import WorkflowJobTemplateEdit from './WorkflowJobTemplateEdit';
+
+jest.mock('@api/models/WorkflowJobTemplates');
+jest.mock('@api/models/Labels');
+jest.mock('@api/models/Organizations');
+jest.mock('@api/models/Inventories');
+
+const mockTemplate = {
+ id: 6,
+ name: 'Foo',
+ description: 'Foo description',
+ summary_fields: {
+ inventory: { id: 1, name: 'Inventory 1' },
+ organization: { id: 1, name: 'Organization 1' },
+ labels: {
+ results: [{ name: 'Label 1', id: 1 }, { name: 'Label 2', id: 2 }],
+ },
+ },
+ scm_branch: 'devel',
+ limit: '5000',
+ variables: '---',
+};
+describe('', () => {
+ let wrapper;
+ let history;
+ beforeEach(async () => {
+ LabelsAPI.read.mockResolvedValue({
+ data: {
+ results: [{ name: 'Label 1', id: 1 }, { name: 'Label 2', id: 2 }],
+ },
+ });
+ OrganizationsAPI.read.mockResolvedValue({ results: [{ id: 1 }] });
+
+ await act(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/templates/workflow_job_template/6/edit'],
+ });
+ wrapper = mountWithContexts(
+ }
+ />,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ match: { params: { id: 6 } },
+ },
+ },
+ },
+ }
+ );
+ });
+ });
+
+ afterEach(async () => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ test('renders successfully', () => {
+ expect(wrapper.find('WorkflowJobTemplateEdit').length).toBe(1);
+ });
+
+ test('api is called to properly to save the updated template.', async () => {
+ await act(async () => {
+ await wrapper.find('WorkflowJobTemplateForm').invoke('handleSubmit')({
+ id: 6,
+ name: 'Alex',
+ description: 'Apollo and Athena',
+ inventory: 1,
+ organization: 1,
+ labels: [{ name: 'Label 2', id: 2 }, { name: 'Generated Label' }],
+ scm_branch: 'master',
+ limit: '5000',
+ variables: '---',
+ });
+ });
+
+ expect(WorkflowJobTemplatesAPI.update).toHaveBeenCalledWith(6, {
+ id: 6,
+ name: 'Alex',
+ description: 'Apollo and Athena',
+ inventory: 1,
+ organization: 1,
+ scm_branch: 'master',
+ limit: '5000',
+ variables: '---',
+ });
+ wrapper.update();
+ await expect(WorkflowJobTemplatesAPI.disassociateLabel).toBeCalledWith(6, {
+ name: 'Label 1',
+ id: 1,
+ });
+ wrapper.update();
+ await expect(WorkflowJobTemplatesAPI.associateLabel).toBeCalledTimes(1);
+ });
+
+ test('handleCancel navigates the user to the /templates', () => {
+ act(() => {
+ wrapper.find('WorkflowJobTemplateForm').invoke('handleCancel')();
+ });
+ expect(history.location.pathname).toBe(
+ '/templates/workflow_job_template/6/details'
+ );
+ });
+
+ test('throwing error renders FormSubmitError component', async () => {
+ const error = {
+ response: {
+ config: {
+ method: 'patch',
+ url: '/api/v2/workflow_job_templates/',
+ },
+ data: { detail: 'An error occurred' },
+ },
+ };
+ WorkflowJobTemplatesAPI.update.mockRejectedValue(error);
+ await act(async () => {
+ wrapper.find('Button[aria-label="Save"]').simulate('click');
+ });
+ expect(WorkflowJobTemplatesAPI.update).toHaveBeenCalled();
+ wrapper.update();
+ expect(wrapper.find('WorkflowJobTemplateForm').prop('submitError')).toEqual(
+ error
+ );
+ });
+
+ test('throwing error prevents form submission', async () => {
+ const templateWithoutOrg = {
+ id: 6,
+ name: 'Foo',
+ description: 'Foo description',
+ summary_fields: {
+ inventory: { id: 1, name: 'Inventory 1' },
+ labels: {
+ results: [{ name: 'Label 1', id: 1 }, { name: 'Label 2', id: 2 }],
+ },
+ },
+ scm_branch: 'devel',
+ limit: '5000',
+ variables: '---',
+ };
+
+ let newWrapper;
+ await act(async () => {
+ newWrapper = await mountWithContexts(
+ ,
+ {
+ context: {
+ router: {
+ history,
+ },
+ },
+ }
+ );
+ });
+ OrganizationsAPI.read.mockRejectedValue({
+ response: {
+ config: {
+ method: 'get',
+ url: '/api/v2/organizations/',
+ },
+ data: { detail: 'An error occurred' },
+ },
+ });
+
+ WorkflowJobTemplatesAPI.update.mockResolvedValue();
+
+ await act(async () => {
+ await newWrapper.find('Button[aria-label="Save"]').simulate('click');
+ });
+ expect(newWrapper.find('WorkflowJobTemplateForm').length).toBe(1);
+ expect(OrganizationsAPI.read).toBeCalled();
+ expect(WorkflowJobTemplatesAPI.update).not.toBeCalled();
+ expect(history.location.pathname).toBe(
+ '/templates/workflow_job_template/6/edit'
+ );
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/index.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/index.js
new file mode 100644
index 0000000000..bd84799fb0
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/index.js
@@ -0,0 +1 @@
+export { default } from './WorkflowJobTemplateEdit';
diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx
index f9f68507cb..19ca4d3467 100644
--- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx
+++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx
@@ -368,9 +368,11 @@ class JobTemplateForm extends Component {
label={i18n._(t`Credentials`)}
promptId="template-ask-credential-on-launch"
promptName="ask_credential_on_launch"
- tooltip={i18n._(
- t`Select credentials that allow Tower to access the nodes this job will be ran against. You can only select one credential of each type. For machine credentials (SSH), checking "Prompt on launch" without selecting credentials will require you to select a machine credential at run time. If you select credentials and check "Prompt on launch", the selected credential(s) become the defaults that can be updated at run time.`
- )}
+ tooltip={i18n._(t`Select credentials that allow Tower to access the nodes this job will be ran
+ against. You can only select one credential of each type. For machine credentials (SSH),
+ checking "Prompt on launch" without selecting credentials will require you to select a machine
+ credential at run time. If you select credentials and check "Prompt on launch", the selected
+ credential(s) become the defaults that can be updated at run time.`)}
>
{({ field }) => {
diff --git a/awx/ui_next/src/screens/Template/shared/SurveyList.jsx b/awx/ui_next/src/screens/Template/shared/SurveyList.jsx
new file mode 100644
index 0000000000..d5ae1a3c35
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/shared/SurveyList.jsx
@@ -0,0 +1,48 @@
+import React, { useEffect, useCallback } from 'react';
+import { withI18n } from '@lingui/react';
+
+import useRequest from '@util/useRequest';
+import ContentError from '@components/ContentError';
+import ContentLoading from '@components/ContentLoading';
+import { JobTemplatesAPI } from '@api';
+import SurveyListItem from './SurveyListItem';
+
+function SurveyList({ template }) {
+ const {
+ result: questions,
+ error: contentError,
+ isLoading,
+ request: fetchSurvey,
+ } = useRequest(
+ useCallback(async () => {
+ const {
+ data: { spec },
+ } = await JobTemplatesAPI.readSurvey(template.id);
+
+ return spec.map((s, index) => ({ ...s, id: index }));
+ }, [template.id])
+ );
+
+ useEffect(() => {
+ fetchSurvey();
+ }, [fetchSurvey]);
+ if (contentError) {
+ return ;
+ }
+ if (isLoading) {
+ return ;
+ }
+
+ return (
+ questions?.length > 0 &&
+ questions.map((question, index) => (
+
+ ))
+ );
+}
+export default withI18n()(SurveyList);
diff --git a/awx/ui_next/src/screens/Template/shared/SurveyList.test.jsx b/awx/ui_next/src/screens/Template/shared/SurveyList.test.jsx
new file mode 100644
index 0000000000..271ddf39bb
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/shared/SurveyList.test.jsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import SurveyList from './SurveyList';
+import { JobTemplatesAPI } from '@api';
+import mockJobTemplateData from './data.job_template.json';
+
+jest.mock('@api/models/JobTemplates');
+
+describe('', () => {
+ beforeEach(() => {
+ JobTemplatesAPI.readSurvey.mockResolvedValue({
+ data: { spec: [{ question_name: 'Foo', type: 'text', default: 'Bar' }] },
+ });
+ });
+ test('expect component to mount successfully', async () => {
+ let wrapper;
+ await act(async () => {
+ wrapper = await mountWithContexts(
+
+ );
+ });
+ expect(wrapper.length).toBe(1);
+ });
+ test('expect api to be called to get survey', async () => {
+ let wrapper;
+ await act(async () => {
+ wrapper = await mountWithContexts(
+
+ );
+ });
+ expect(JobTemplatesAPI.readSurvey).toBeCalledWith(7);
+ wrapper.update();
+ expect(wrapper.find('SurveyListItem').length).toBe(1);
+ });
+ test('error in retrieving the survey throws an error', async () => {
+ JobTemplatesAPI.readSurvey.mockRejectedValue(new Error());
+ let wrapper;
+ await act(async () => {
+ wrapper = await mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('ContentError').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/shared/SurveyListItem.jsx b/awx/ui_next/src/screens/Template/shared/SurveyListItem.jsx
new file mode 100644
index 0000000000..f50b428c25
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/shared/SurveyListItem.jsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import { t } from '@lingui/macro';
+import { withI18n } from '@lingui/react';
+
+import {
+ Button as _Button,
+ DataList,
+ DataListAction as _DataListAction,
+ DataListCheck,
+ DataListItemCells,
+ DataListItemRow,
+ DataListItem,
+ DataListCell,
+ Stack,
+ StackItem,
+} from '@patternfly/react-core';
+import { CaretDownIcon, CaretUpIcon } from '@patternfly/react-icons';
+import styled from 'styled-components';
+
+const DataListAction = styled(_DataListAction)`
+ margin-left: 0;
+ margin-right: 20px;
+ padding-top: 15px;
+ padding-bottom: 15px;
+`;
+const Button = styled(_Button)`
+ padding-top: 0;
+ padding-bottom: 0;
+ padding-left: 0;
+`;
+function SurveyListItem({ question, i18n, isLast, isFirst }) {
+ return (
+
+ );
+}
+
+export default withI18n()(SurveyListItem);
diff --git a/awx/ui_next/src/screens/Template/shared/SurveyListItem.test.jsx b/awx/ui_next/src/screens/Template/shared/SurveyListItem.test.jsx
new file mode 100644
index 0000000000..2c55648eee
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/shared/SurveyListItem.test.jsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import SurveyListItem from './SurveyListItem';
+
+describe('', () => {
+ const item = { question_name: 'Foo', default: 'Bar', type: 'text' };
+ test('renders successfully', () => {
+ let wrapper;
+ act(() => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ expect(wrapper.length).toBe(1);
+ });
+ test('fields are rendering properly', () => {
+ let wrapper;
+ act(() => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ const moveUp = wrapper.find('Button[aria-label="move up"]');
+ const moveDown = wrapper.find('Button[aria-label="move down"]');
+ expect(moveUp.length).toBe(1);
+ expect(moveDown.length).toBe(1);
+ expect(wrapper.find('DataListCheck').length).toBe(1);
+ expect(wrapper.find('DataListCell').length).toBe(3);
+ });
+ test('move up and move down buttons are disabled', () => {
+ let wrapper;
+ act(() => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ const moveUp = wrapper
+ .find('Button[aria-label="move up"]')
+ .prop('isDisabled');
+ const moveDown = wrapper
+ .find('Button[aria-label="move down"]')
+ .prop('isDisabled');
+ expect(moveUp).toBe(true);
+ expect(moveDown).toBe(true);
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx
new file mode 100644
index 0000000000..770f665982
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx
@@ -0,0 +1,509 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { t } from '@lingui/macro';
+import { useRouteMatch, useParams } from 'react-router-dom';
+
+import { func, shape } from 'prop-types';
+
+import { withI18n } from '@lingui/react';
+import { Formik, Field } from 'formik';
+import {
+ Form,
+ FormGroup,
+ InputGroup,
+ Button,
+ TextInput,
+ Checkbox,
+} from '@patternfly/react-core';
+import { required } from '@util/validators';
+import { SyncAltIcon } from '@patternfly/react-icons';
+
+import AnsibleSelect from '@components/AnsibleSelect';
+import { WorkflowJobTemplatesAPI, CredentialTypesAPI } from '@api';
+
+import useRequest from '@util/useRequest';
+import FormField, {
+ FieldTooltip,
+ FormSubmitError,
+} from '@components/FormField';
+import {
+ FormColumnLayout,
+ FormFullWidthLayout,
+ FormCheckboxLayout,
+} from '@components/FormLayout';
+import ContentLoading from '@components/ContentLoading';
+import OrganizationLookup from '@components/Lookup/OrganizationLookup';
+import CredentialLookup from '@components/Lookup/CredentialLookup';
+import { InventoryLookup } from '@components/Lookup';
+import { VariablesField } from '@components/CodeMirrorInput';
+import FormActionGroup from '@components/FormActionGroup';
+import ContentError from '@components/ContentError';
+import CheckboxField from '@components/FormField/CheckboxField';
+import LabelSelect from './LabelSelect';
+
+function WorkflowJobTemplateForm({
+ handleSubmit,
+ handleCancel,
+ i18n,
+ template = {},
+ webhook_key,
+ submitError,
+}) {
+ const urlOrigin = window.location.origin;
+ const { id } = useParams();
+ const wfjtAddMatch = useRouteMatch('/templates/workflow_job_template/add');
+
+ const [hasContentError, setContentError] = useState(null);
+ const [webhook_url, setWebhookUrl] = useState(
+ template?.related?.webhook_receiver
+ ? `${urlOrigin}${template.related.webhook_receiver}`
+ : ''
+ );
+ const [inventory, setInventory] = useState(
+ template?.summary_fields?.inventory || null
+ );
+ const [organization, setOrganization] = useState(
+ template?.summary_fields?.organization || null
+ );
+ const [webhookCredential, setWebhookCredential] = useState(
+ template?.summary_fields?.webhook_credential || null
+ );
+ const [webhookKey, setWebHookKey] = useState(webhook_key);
+ const [webhookService, setWebHookService] = useState(
+ template.webhook_service || ''
+ );
+ const [hasWebhooks, setHasWebhooks] = useState(Boolean(webhookService));
+
+ const webhookServiceOptions = [
+ {
+ value: '',
+ key: '',
+ label: i18n._(t`Choose a Webhook Service`),
+ isDisabled: true,
+ },
+ {
+ value: 'github',
+ key: 'github',
+ label: i18n._(t`GitHub`),
+ isDisabled: false,
+ },
+ {
+ value: 'gitlab',
+ key: 'gitlab',
+ label: i18n._(t`GitLab`),
+ isDisabled: false,
+ },
+ ];
+ const {
+ request: loadCredentialType,
+ error: contentError,
+ contentLoading,
+ result: credTypeId,
+ } = useRequest(
+ useCallback(async () => {
+ let results;
+ if (webhookService) {
+ results = await CredentialTypesAPI.read({
+ namespace: `${webhookService}_token`,
+ });
+ // TODO: Consider how to handle the situation where the results returns
+ // and empty array, or any of the other values is undefined or null (data, results, id)
+ }
+ return results?.data?.results[0]?.id;
+ }, [webhookService])
+ );
+
+ useEffect(() => {
+ loadCredentialType();
+ }, [loadCredentialType]);
+
+ // TODO: Convert this function below to useRequest. Might want to create a new
+ // webhookkey component that handles all of that api calls. Will also need
+ // to move this api call out of WorkflowJobTemplate.jsx and add it to workflowJobTemplateDetai.jsx
+ const changeWebhookKey = async () => {
+ try {
+ const {
+ data: { webhook_key: key },
+ } = await WorkflowJobTemplatesAPI.updateWebhookKey(id);
+ setWebHookKey(key);
+ } catch (err) {
+ setContentError(err);
+ }
+ };
+
+ let initialWebhookKey = webhook_key;
+ const initialWebhookCredential = template?.summary_fields?.webhook_credential;
+
+ const storeWebhookValues = (form, webhookServiceValue) => {
+ if (
+ webhookServiceValue === form.initialValues.webhook_service ||
+ webhookServiceValue === ''
+ ) {
+ form.setFieldValue(
+ 'webhook_credential',
+ form.initialValues.webhook_credential
+ );
+ setWebhookCredential(initialWebhookCredential);
+
+ setWebhookUrl(
+ template?.related?.webhook_receiver
+ ? `${urlOrigin}${template.related.webhook_receiver}`
+ : ''
+ );
+ form.setFieldValue('webhook_service', form.initialValues.webhook_service);
+ setWebHookService(form.initialValues.webhook_service);
+
+ setWebHookKey(initialWebhookKey);
+ } else {
+ form.setFieldValue('webhook_credential', null);
+ setWebhookCredential(null);
+
+ setWebhookUrl(
+ `${urlOrigin}/api/v2/workflow_job_templates/${template.id}/${webhookServiceValue}/`
+ );
+
+ setWebHookKey(
+ i18n._(t`a new webhook key will be generated on save.`).toUpperCase()
+ );
+ }
+ };
+
+ const handleWebhookEnablement = (
+ form,
+ enabledWebhooks,
+ webhookServiceValue
+ ) => {
+ if (!enabledWebhooks) {
+ initialWebhookKey = webhookKey;
+ form.setFieldValue('webhook_credential', null);
+ form.setFieldValue('webhook_service', '');
+ setWebhookUrl('');
+ setWebHookService('');
+ setWebHookKey('');
+ } else {
+ storeWebhookValues(form, webhookServiceValue);
+ }
+ };
+
+ if (hasContentError || contentError) {
+ return ;
+ }
+
+ if (contentLoading) {
+ return ;
+ }
+
+ return (
+ {
+ if (values.webhook_service === '') {
+ values.webhook_credential = '';
+ }
+ return handleSubmit(values);
+ }}
+ initialValues={{
+ name: template.name || '',
+ description: template.description || '',
+ inventory: template?.summary_fields?.inventory?.id || null,
+ organization: template?.summary_fields?.organization?.id || null,
+ labels: template.summary_fields?.labels?.results || [],
+ extra_vars: template.extra_vars || '---',
+ limit: template.limit || '',
+ scm_branch: template.scm_branch || '',
+ allow_simultaneous: template.allow_simultaneous || false,
+ webhook_credential:
+ template?.summary_fields?.webhook_credential?.id || null,
+ webhook_service: template.webhook_service || '',
+ ask_limit_on_launch: template.ask_limit_on_launch || false,
+ ask_inventory_on_launch: template.ask_inventory_on_launch || false,
+ ask_variables_on_launch: template.ask_variables_on_launch || false,
+ ask_scm_branch_on_launch: template.ask_scm_branch_on_launch || false,
+ }}
+ >
+ {formik => (
+
+ )}
+
+ );
+}
+
+WorkflowJobTemplateForm.propTypes = {
+ handleSubmit: func.isRequired,
+ handleCancel: func.isRequired,
+ submitError: shape({}),
+};
+
+WorkflowJobTemplateForm.defaultProps = {
+ submitError: null,
+};
+
+export default withI18n()(WorkflowJobTemplateForm);
diff --git a/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.test.jsx b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.test.jsx
new file mode 100644
index 0000000000..904ac78204
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.test.jsx
@@ -0,0 +1,229 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { Route } from 'react-router-dom';
+import { createMemoryHistory } from 'history';
+import { sleep } from '@testUtils/testUtils';
+
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import WorkflowJobTemplateForm from './WorkflowJobTemplateForm';
+import {
+ WorkflowJobTemplatesAPI,
+ LabelsAPI,
+ OrganizationsAPI,
+ InventoriesAPI,
+} from '@api';
+
+jest.mock('@api/models/WorkflowJobTemplates');
+jest.mock('@api/models/Labels');
+jest.mock('@api/models/Organizations');
+jest.mock('@api/models/Inventories');
+
+describe('', () => {
+ let wrapper;
+ let history;
+ const handleSubmit = jest.fn();
+ const handleCancel = jest.fn();
+ const mockTemplate = {
+ id: 6,
+ name: 'Foo',
+ description: 'Foo description',
+ summary_fields: {
+ inventory: { id: 1, name: 'Inventory 1' },
+ organization: { id: 1, name: 'Organization 1' },
+ labels: {
+ results: [{ name: 'Label 1', id: 1 }, { name: 'Label 2', id: 2 }],
+ },
+ },
+ scm_branch: 'devel',
+ limit: '5000',
+ variables: '---',
+ related: {
+ webhook_receiver: '/api/v2/workflow_job_templates/57/gitlab/',
+ },
+ };
+
+ beforeEach(async () => {
+ WorkflowJobTemplatesAPI.updateWebhookKey.mockResolvedValue({
+ data: { webhook_key: 'sdafdghjkl2345678ionbvcxz' },
+ });
+ LabelsAPI.read.mockResolvedValue({
+ data: {
+ results: [
+ { name: 'Label 1', id: 1 },
+ { name: 'Label 2', id: 2 },
+ { name: 'Label 3', id: 3 },
+ ],
+ },
+ });
+ OrganizationsAPI.read.mockResolvedValue({
+ results: [{ id: 1 }, { id: 2 }],
+ });
+ InventoriesAPI.read.mockResolvedValue({
+ results: [{ id: 1, name: 'Foo' }, { id: 2, name: 'Bar' }],
+ });
+
+ history = createMemoryHistory({
+ initialEntries: ['/templates/workflow_job_template/6/edit'],
+ });
+ await act(async () => {
+ wrapper = await mountWithContexts(
+ (
+
+ )}
+ />,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ match: { params: { id: 6 } },
+ },
+ },
+ },
+ }
+ );
+ });
+ });
+
+ afterEach(() => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ test('renders successfully', () => {
+ expect(wrapper.length).toBe(1);
+ });
+
+ test('all the fields render successfully', () => {
+ const fields = [
+ 'FormField[name="name"]',
+ 'FormField[name="description"]',
+ 'Field[name="organization"]',
+ 'Field[name="inventory"]',
+ 'FormField[name="limit"]',
+ 'FormField[name="scm_branch"]',
+ 'Field[name="labels"]',
+ 'VariablesField',
+ ];
+ const assertField = field => {
+ expect(wrapper.find(`${field}`).length).toBe(1);
+ };
+ fields.map((field, index) => assertField(field, index));
+ });
+
+ test('changing inputs should update values', async () => {
+ const inputsToChange = [
+ {
+ element: 'wfjt-name',
+ value: { value: 'new foo', name: 'name' },
+ },
+ {
+ element: 'wfjt-description',
+ value: { value: 'new bar', name: 'description' },
+ },
+ { element: 'wfjt-limit', value: { value: 1234567890, name: 'limit' } },
+ {
+ element: 'wfjt-scm_branch',
+ value: { value: 'new branch', name: 'scm_branch' },
+ },
+ ];
+ const changeInputs = async ({ element, value }) => {
+ wrapper.find(`input#${element}`).simulate('change', {
+ target: { value: `${value.value}`, name: `${value.name}` },
+ });
+ };
+
+ await act(async () => {
+ inputsToChange.map(input => changeInputs(input));
+
+ wrapper.find('LabelSelect').invoke('onChange')([
+ { name: 'Label 3', id: 3 },
+ { name: 'Label 1', id: 1 },
+ { name: 'Label 2', id: 2 },
+ ]);
+ wrapper.find('InventoryLookup').invoke('onChange')({
+ id: 3,
+ name: 'inventory',
+ });
+ wrapper.find('OrganizationLookup').invoke('onChange')({
+ id: 3,
+ name: 'organization',
+ });
+ });
+ wrapper.update();
+
+ const assertChanges = ({ element, value }) => {
+ expect(wrapper.find(`input#${element}`).prop('value')).toEqual(
+ `${value.value}`
+ );
+ };
+
+ inputsToChange.map(input => assertChanges(input));
+ });
+
+ test('webhooks and enable concurrent jobs functions properly', async () => {
+ act(() => {
+ wrapper.find('Checkbox[aria-label="Enable Webhook"]').invoke('onChange')(
+ true,
+ {
+ currentTarget: { value: true, type: 'change', checked: true },
+ }
+ );
+ });
+ wrapper.update();
+ expect(
+ wrapper.find('Checkbox[aria-label="Enable Webhook"]').prop('isChecked')
+ ).toBe(true);
+
+ expect(
+ wrapper.find('input[aria-label="wfjt-webhook-key"]').prop('readOnly')
+ ).toBe(true);
+ expect(
+ wrapper.find('input[aria-label="wfjt-webhook-key"]').prop('value')
+ ).toBe('sdfghjklmnbvcdsew435678iokjhgfd');
+ await act(() =>
+ wrapper
+ .find('FormGroup[name="webhook_key"]')
+ .find('Button[variant="tertiary"]')
+ .prop('onClick')()
+ );
+ expect(WorkflowJobTemplatesAPI.updateWebhookKey).toBeCalledWith('6');
+ expect(
+ wrapper.find('TextInputBase[aria-label="Webhook URL"]').prop('value')
+ ).toContain('/api/v2/workflow_job_templates/57/gitlab/');
+
+ wrapper.update();
+
+ expect(wrapper.find('Field[name="webhook_service"]').length).toBe(1);
+
+ act(() => wrapper.find('AnsibleSelect').prop('onChange')({}, 'gitlab'));
+ wrapper.update();
+
+ expect(wrapper.find('AnsibleSelect').prop('value')).toBe('gitlab');
+ });
+
+ test('handleSubmit is called on submit button click', async () => {
+ act(() => {
+ wrapper.find('Formik').prop('onSubmit')({});
+ });
+ wrapper.update();
+ sleep(0);
+ expect(handleSubmit).toBeCalled();
+ });
+
+ test('handleCancel is called on cancel button click', async () => {
+ act(() => {
+ wrapper.find('button[aria-label="Cancel"]').simulate('click');
+ });
+
+ expect(handleCancel).toBeCalled();
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/shared/index.js b/awx/ui_next/src/screens/Template/shared/index.js
index 4e4c871715..a446c029c5 100644
--- a/awx/ui_next/src/screens/Template/shared/index.js
+++ b/awx/ui_next/src/screens/Template/shared/index.js
@@ -1 +1,2 @@
-export { default } from './JobTemplateForm';
+export { default as JobTemplateForm } from './JobTemplateForm';
+export { default as WorkflowJobTemplateForm } from './WorkflowJobTemplateForm';
diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js
index b4270c707d..c1b592cb5a 100644
--- a/awx/ui_next/src/types.js
+++ b/awx/ui_next/src/types.js
@@ -291,7 +291,7 @@ export const Schedule = shape({
skip_tags: string,
limit: string,
diff_mode: bool,
- verbosity: string,
+ verbosity: number,
unified_job_template: number,
enabled: bool,
dtstart: string,
diff --git a/awx/ui_next/src/util/dates.jsx b/awx/ui_next/src/util/dates.jsx
index 87c362414a..ca897142e4 100644
--- a/awx/ui_next/src/util/dates.jsx
+++ b/awx/ui_next/src/util/dates.jsx
@@ -5,6 +5,10 @@ export function formatDateString(dateString, lang = getLanguage(navigator)) {
return new Date(dateString).toLocaleString(lang);
}
+export function formatDateStringUTC(dateString, lang = getLanguage(navigator)) {
+ return new Date(dateString).toLocaleString(lang, { timeZone: 'UTC' });
+}
+
export function secondsToHHMMSS(seconds) {
return new Date(seconds * 1000).toISOString().substr(11, 8);
}
diff --git a/awx_collection/README.md b/awx_collection/README.md
index f91396e59c..c9abe15542 100644
--- a/awx_collection/README.md
+++ b/awx_collection/README.md
@@ -8,6 +8,25 @@ inside the folder `lib/ansible/modules/web_infrastructure/ansible_tower`
as well as other folders for the inventory plugin, module utils, and
doc fragment.
+## Building and Installing
+
+This collection templates the `galaxy.yml` file it uses.
+Run `make build_collection` from the root folder of the AWX source tree.
+This will create the `tar.gz` file inside the `awx_collection` folder
+with the current AWX version, for example: `awx_collection/awx-awx-9.2.0.tar.gz`.
+
+Installing the `tar.gz` involves no special instructions.
+
+## Running
+
+Modules in this collection may have any of the following python requirements:
+
+ - the official [AWX CLI](https://docs.ansible.com/ansible-tower/latest/html/towercli/index.html)
+ - the deprecated `tower-cli` [PyPI](https://pypi.org/project/ansible-tower-cli/)
+ - no requirements
+
+See requirements in the `DOCUMENTATION` string specific to each module.
+
## Release and Upgrade Notes
The release 7.0.0 of the `awx.awx` collection is intended to be identical
@@ -27,16 +46,6 @@ The following notes are changes that may require changes to playbooks:
- The `variables` parameter in the `tower_group`, `tower_host` and `tower_inventory` modules are now in `dict` format and no longer supports the use of the `C(@)` syntax (for an external `vars` file).
- Some return values (e.g., `credential_type`) have been removed. Use of `id` is recommended.
-## Running
-
-To use this collection, the "old" `tower-cli` needs to be installed
-in the virtual environment where the collection runs.
-You can install it from [PyPI](https://pypi.org/project/ansible-tower-cli/).
-
-To use this collection in AWX, you should create a custom virtual environment into which to install the requirements. NOTE: running locally, you will also need
-to set the job template `extra_vars` to include `ansible_python_interpreter`
-to be the Python in that virtual environment.
-
## Running Unit Tests
Tests to verify compatibility with the most recent AWX code are
@@ -71,7 +80,7 @@ pip install -e .
PYTHONPATH=awx_collection:$PYTHONPATH py.test awx_collection/test/awx/
```
-## Running Integration tests Tests
+## Running Integration Tests
The integration tests require a virtualenv with `ansible` >= 2.9 and `tower_cli`.
The collection must first be installed, which can be done using `make install_collection`.
@@ -95,12 +104,6 @@ cd ~/.ansible/collections/ansible_collections/awx/awx/
ansible-test integration
```
-## Building
-
-The build target `make build_collection` will template out a `galaxy.yml` file
-with automatic detection of the current AWX version. Then it builds the
-collection with the `ansible-galaxy` CLI.
-
## Licensing
All content in this folder is licensed under the same license as Ansible,
diff --git a/awx_collection/template_galaxy.yml b/awx_collection/template_galaxy.yml
index adf302aa14..efe200a440 100644
--- a/awx_collection/template_galaxy.yml
+++ b/awx_collection/template_galaxy.yml
@@ -23,7 +23,7 @@
replace: 'extends_documentation_fragment: {{ collection_namespace }}.{{ collection_package }}.auth'
loop: "{{ module_files.files }}"
loop_control:
- label: "{{ item.path[ playbook_dir | length :] }}"
+ label: "{{ item.path | basename }}"
- name: Change files to support desired namespace and package names
replace: