From 8cae728ea01478f4b8f83ca52ddcd53bdad16abf Mon Sep 17 00:00:00 2001 From: dejongm Date: Thu, 7 Jan 2021 10:27:03 -0500 Subject: [PATCH 001/397] Update defaults.py --- awx/settings/defaults.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 51e15a5e43..89b189b967 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -718,10 +718,10 @@ TOWER_INSTANCE_ID_VAR = 'remote_tower_id' # --------------------- # ----- Foreman ----- # --------------------- -SATELLITE6_ENABLED_VAR = 'foreman.enabled' +SATELLITE6_ENABLED_VAR = 'foreman_enabled' SATELLITE6_ENABLED_VALUE = 'True' SATELLITE6_EXCLUDE_EMPTY_GROUPS = True -SATELLITE6_INSTANCE_ID_VAR = 'foreman.id' +SATELLITE6_INSTANCE_ID_VAR = 'foreman_id' # SATELLITE6_GROUP_PREFIX and SATELLITE6_GROUP_PATTERNS defined in source vars # --------------------- From 5445de7d01696fab816f4a30752e7d4e445c3037 Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Thu, 21 Jan 2021 20:25:06 +0100 Subject: [PATCH 002/397] Document admin_password in INSTALL.md Because the first user that ever logs in shouldn't be an automated bot looking for vulnerable webservers. --- INSTALL.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/INSTALL.md b/INSTALL.md index f7ab93a6e3..357dea1cc1 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -479,6 +479,10 @@ If you choose to use the official images then the remote host will be the one to Before starting the install process, review the [inventory](./installer/inventory) file, and uncomment and provide values for the following variables found in the `[all:vars]` section: +*admin_password* + +> Provide a strong password to prevent malicious logins after the installation + *postgres_data_dir* > If you're using the default PostgreSQL container (see [PostgreSQL](#postgresql-1) below), provide a path that can be mounted to the container, and where the database can be persisted. From 7d58ae3c8c0b8390fd6f175e079a53aaea303a75 Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Thu, 21 Jan 2021 20:27:33 +0100 Subject: [PATCH 003/397] dot --- INSTALL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/INSTALL.md b/INSTALL.md index 357dea1cc1..d96c2a086e 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -481,7 +481,7 @@ Before starting the install process, review the [inventory](./installer/inventor *admin_password* -> Provide a strong password to prevent malicious logins after the installation +> Provide a strong password to prevent malicious logins after the installation. *postgres_data_dir* From fe9bd37c74427c228f365965b45bb35b3c2a3ff2 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Fri, 8 Jan 2021 14:42:45 -0500 Subject: [PATCH 004/397] fixes pagination issue on modal --- awx/ui_next/src/components/SelectedList/SelectedList.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui_next/src/components/SelectedList/SelectedList.jsx b/awx/ui_next/src/components/SelectedList/SelectedList.jsx index a36eab044a..b351440ec8 100644 --- a/awx/ui_next/src/components/SelectedList/SelectedList.jsx +++ b/awx/ui_next/src/components/SelectedList/SelectedList.jsx @@ -6,7 +6,7 @@ import styled from 'styled-components'; import ChipGroup from '../ChipGroup'; const Split = styled(PFSplit)` - margin: 20px 0px; + margin: 20px 0 5px 0; align-items: baseline; `; From 82a226d1feffb454787a94318bbe9fd7cda38502 Mon Sep 17 00:00:00 2001 From: Hideki Saito Date: Sun, 3 Jan 2021 11:55:26 +0900 Subject: [PATCH 005/397] Add links to inventory source and project to inventory update details view of Jobs list * Addresses #8839 Signed-off-by: Hideki Saito --- awx/api/serializers.py | 2 +- .../src/screens/Job/JobDetail/JobDetail.jsx | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index ecce831a19..d34c0d924a 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -124,7 +124,7 @@ SUMMARIZABLE_FK_FIELDS = { 'last_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'), 'current_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'), 'current_job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'), - 'inventory_source': ('source', 'last_updated', 'status'), + 'inventory_source': ('id', 'name', 'source', 'last_updated', 'status'), 'custom_inventory_script': DEFAULT_SUMMARY_FIELDS, 'source_script': DEFAULT_SUMMARY_FIELDS, 'role': ('id', 'role_field'), diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx index d41632008b..3e0c40fd3f 100644 --- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx +++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx @@ -61,6 +61,8 @@ function JobDetail({ job, i18n }) { credentials, instance_group: instanceGroup, inventory, + inventory_source, + source_project, job_template: jobTemplate, workflow_job_template: workflowJobTemplate, labels, @@ -203,6 +205,33 @@ function JobDetail({ job, i18n }) { } /> )} + {inventory_source && ( + + {inventory_source.name} + + } + /> + )} + {inventory_source && inventory_source.source === 'scm' && ( + + {source_project.status && ( + + )} + + {source_project.name} + + + } + /> + )} {project && ( Date: Tue, 16 Feb 2021 18:19:37 +0530 Subject: [PATCH 006/397] add workflow_job_launch_type in metavars --- awx/main/models/unified_jobs.py | 1 + awx/main/tests/functional/models/test_unified_job.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index ad32da1d24..064585c6c1 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -1453,6 +1453,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique for name in ('awx', 'tower'): r['{}_workflow_job_id'.format(name)] = wj.pk r['{}_workflow_job_name'.format(name)] = wj.name + r['{}_workflow_job_launch_type'.format(name)] = wj.launch_type if schedule: r['{}_parent_job_schedule_id'.format(name)] = schedule.pk r['{}_parent_job_schedule_name'.format(name)] = schedule.name diff --git a/awx/main/tests/functional/models/test_unified_job.py b/awx/main/tests/functional/models/test_unified_job.py index 7b5f6d432b..c8376a9728 100644 --- a/awx/main/tests/functional/models/test_unified_job.py +++ b/awx/main/tests/functional/models/test_unified_job.py @@ -154,6 +154,7 @@ class TestMetaVars: assert data['awx_user_id'] == admin_user.id assert data['awx_user_name'] == admin_user.username assert data['awx_workflow_job_id'] == workflow_job.pk + assert data['awx_workflow_job_launch_type'] == workflow_job.launch_type def test_scheduled_job_metavars(self, job_template, admin_user): schedule = Schedule.objects.create( @@ -197,6 +198,8 @@ class TestMetaVars: 'tower_workflow_job_name': 'workflow-job', 'awx_workflow_job_id': workflow_job.id, 'tower_workflow_job_id': workflow_job.id, + 'awx_workflow_job_launch_type': workflow_job.launch_type, + 'tower_workflow_job_launch_type': workflow_job.launch_type, 'awx_parent_job_schedule_id': schedule.id, 'tower_parent_job_schedule_id': schedule.id, 'awx_parent_job_schedule_name': 'job-schedule', From b3cdefec23d9dd5455f0de7b12c1ac2fbe28bd4e Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Fri, 29 Jan 2021 14:44:39 -0800 Subject: [PATCH 007/397] convert CredentialTypeList to tables --- .../CredentialTypeList/CredentialTypeList.jsx | 17 +++- .../CredentialTypeList.test.jsx | 22 ++++- .../CredentialTypeListItem.jsx | 95 +++++++----------- .../CredentialTypeListItem.test.jsx | 96 +++++++++---------- 4 files changed, 112 insertions(+), 118 deletions(-) diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx index ee47d2fdbc..7c491bd36a 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx @@ -8,10 +8,14 @@ import { CredentialTypesAPI } from '../../../api'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import useRequest, { useDeleteItems } from '../../../util/useRequest'; import useSelected from '../../../util/useSelected'; -import PaginatedDataList, { +import { ToolbarDeleteButton, ToolbarAddButton, } from '../../../components/PaginatedDataList'; +import PaginatedTable, { + HeaderRow, + HeaderCell, +} from '../../../components/PaginatedTable'; import ErrorDetail from '../../../components/ErrorDetail'; import AlertModal from '../../../components/AlertModal'; import DatalistToolbar from '../../../components/DataListToolbar'; @@ -106,7 +110,7 @@ function CredentialTypeList({ i18n }) { <> - )} - renderItem={credentialType => ( + headerRow={ + + {i18n._(t`Name`)} + {i18n._(t`Actions`)} + + } + renderRow={(credentialType, index) => ( handleSelect(credentialType)} isSelected={selected.some(row => row.id === credentialType.id)} + rowIndex={index} /> )} emptyStateControls={ diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.test.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.test.jsx index adeda5255b..16f5f4daf7 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.test.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.test.jsx @@ -72,12 +72,18 @@ describe(' { await waitForElement(wrapper, 'CredentialTypeList', el => el.length > 0); wrapper - .find('input#select-credential-types-1') + .find('.pf-c-table__check') + .first() + .find('input') .simulate('change', credentialTypes.data.results[0]); wrapper.update(); expect( - wrapper.find('input#select-credential-types-1').prop('checked') + wrapper + .find('.pf-c-table__check') + .first() + .find('input') + .prop('checked') ).toBe(true); await act(async () => { @@ -133,10 +139,18 @@ describe(' { }); waitForElement(wrapper, 'CredentialTypeList', el => el.length > 0); - wrapper.find('input#select-credential-types-1').simulate('change', 'a'); + wrapper + .find('.pf-c-table__check') + .first() + .find('input') + .simulate('change', 'a'); wrapper.update(); expect( - wrapper.find('input#select-credential-types-1').prop('checked') + wrapper + .find('.pf-c-table__check') + .first() + .find('input') + .prop('checked') ).toBe(true); await act(async () => diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.jsx index d6dd1cf1fa..ea461b58bb 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.jsx @@ -3,82 +3,53 @@ import { string, bool, func } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Link } from 'react-router-dom'; -import { - Button, - DataListAction as _DataListAction, - DataListCheck, - DataListItem, - DataListItemRow, - DataListItemCells, - Tooltip, -} from '@patternfly/react-core'; +import { Button } from '@patternfly/react-core'; +import { Tr, Td } from '@patternfly/react-table'; import { PencilAltIcon } from '@patternfly/react-icons'; -import styled from 'styled-components'; - -import DataListCell from '../../../components/DataListCell'; +import { ActionsTd, ActionItem } from '../../../components/PaginatedTable'; import { CredentialType } from '../../../types'; -const DataListAction = styled(_DataListAction)` - align-items: center; - display: grid; - grid-gap: 16px; - grid-template-columns: 40px; -`; - function CredentialTypeListItem({ credentialType, detailUrl, isSelected, onSelect, + rowIndex, i18n, }) { const labelId = `check-action-${credentialType.id}`; return ( - - - - - - {credentialType.name} - - , - ]} - /> - + + + + {credentialType.name} + + + + - {credentialType.summary_fields.user_capabilities.edit && ( - - - - )} - - - + + + + ); } diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.test.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.test.jsx index 3cb4f6cc52..45c78d918e 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.test.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeListItem.test.jsx @@ -17,12 +17,16 @@ describe('', () => { test('should mount successfully', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + + {}} + /> + +
); }); expect(wrapper.find('CredentialTypeListItem').length).toBe(1); @@ -31,48 +35,38 @@ describe('', () => { test('should render the proper data', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + + {}} + /> + +
); }); - expect( - wrapper.find('DataListCell[aria-label="credential type name"]').text() - ).toBe('Foo'); + expect(wrapper.find('Td[dataLabel="Name"]').text()).toBe('Foo'); expect(wrapper.find('PencilAltIcon').length).toBe(1); - expect( - wrapper.find('input#select-credential-types-1').prop('checked') - ).toBe(false); - }); - - test('should be checked', async () => { - await act(async () => { - wrapper = mountWithContexts( - {}} - /> - ); - }); - expect( - wrapper.find('input#select-credential-types-1').prop('checked') - ).toBe(true); + expect(wrapper.find('.pf-c-table__check input').prop('checked')).toBe( + undefined + ); }); test('edit button shown to users with edit capabilities', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + + {}} + /> + +
); }); @@ -82,15 +76,19 @@ describe('', () => { test('edit button hidden from users without edit capabilities', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + + {}} + /> + +
); }); From c3bab52a61fbe865055fbe221172a5cec5204ae0 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Fri, 29 Jan 2021 15:04:11 -0800 Subject: [PATCH 008/397] convert notification template list to tables --- .../NotificationTemplateList.jsx | 31 ++-- .../NotificationTemplateList.test.jsx | 28 +++- .../NotificationTemplateListItem.jsx | 146 +++++++----------- .../NotificationTemplateListItem.test.jsx | 90 ++++++----- 4 files changed, 152 insertions(+), 143 deletions(-) diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.jsx index a377e73e79..c1b277b730 100644 --- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.jsx +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.jsx @@ -4,7 +4,11 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Card, PageSection } from '@patternfly/react-core'; import { NotificationTemplatesAPI } from '../../../api'; -import PaginatedDataList, { +import PaginatedTable, { + HeaderRow, + HeaderCell, +} from '../../../components/PaginatedTable'; +import { ToolbarAddButton, ToolbarDeleteButton, } from '../../../components/PaginatedDataList'; @@ -104,7 +108,7 @@ function NotificationTemplatesList({ i18n }) { <> - ( )} - renderItem={template => ( + headerRow={ + + {i18n._(t`Name`)} + {i18n._(t`Status`)} + + {i18n._(t`Type`)} + + {i18n._(t`Actions`)} + + } + renderRow={(template, index) => ( row.id === template.id)} onSelect={() => handleSelect(template)} + rowIndex={index} /> )} emptyStateControls={ diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.test.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.test.jsx index d39bffe087..345d2d0a42 100644 --- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.test.jsx +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateList.test.jsx @@ -89,21 +89,33 @@ describe('', () => { }); test('should select item', async () => { - const itemCheckboxInput = 'input#select-template-1'; await act(async () => { wrapper = mountWithContexts(); }); wrapper.update(); - expect(wrapper.find(itemCheckboxInput).prop('checked')).toEqual(false); + expect( + wrapper + .find('.pf-c-table__check') + .first() + .find('input') + .prop('checked') + ).toEqual(false); await act(async () => { wrapper - .find(itemCheckboxInput) - .closest('DataListCheck') + .find('.pf-c-table__check') + .first() + .find('input') .props() .onChange(); }); wrapper.update(); - expect(wrapper.find(itemCheckboxInput).prop('checked')).toEqual(true); + expect( + wrapper + .find('.pf-c-table__check') + .first() + .find('input') + .prop('checked') + ).toEqual(true); }); test('should delete notifications', async () => { @@ -135,7 +147,6 @@ describe('', () => { }); test('should show error dialog shown for failed deletion', async () => { - const itemCheckboxInput = 'input#select-template-1'; OrganizationsAPI.destroy.mockRejectedValue( new Error({ response: { @@ -153,8 +164,9 @@ describe('', () => { wrapper.update(); await act(async () => { wrapper - .find(itemCheckboxInput) - .closest('DataListCheck') + .find('.pf-c-table__check') + .first() + .find('input') .props() .onChange(); }); diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.jsx index f3386f12a9..f3d99b76db 100644 --- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.jsx +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.jsx @@ -3,32 +3,17 @@ import React, { useState, useEffect, useCallback } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Link } from 'react-router-dom'; -import styled from 'styled-components'; -import { - Button, - DataListAction as _DataListAction, - DataListCheck, - DataListItem, - DataListItemCells, - DataListItemRow, - Tooltip, -} from '@patternfly/react-core'; +import { Button } from '@patternfly/react-core'; +import { Tr, Td } from '@patternfly/react-table'; import { PencilAltIcon, BellIcon } from '@patternfly/react-icons'; +import { ActionsTd, ActionItem } from '../../../components/PaginatedTable'; import { timeOfDay } from '../../../util/dates'; import { NotificationTemplatesAPI, NotificationsAPI } from '../../../api'; -import DataListCell from '../../../components/DataListCell'; import StatusLabel from '../../../components/StatusLabel'; import CopyButton from '../../../components/CopyButton'; import useRequest from '../../../util/useRequest'; import { NOTIFICATION_TYPES } from '../constants'; -const DataListAction = styled(_DataListAction)` - align-items: center; - display: grid; - grid-gap: 16px; - grid-template-columns: repeat(3, 40px); -`; - const NUM_RETRIES = 25; const RETRY_TIMEOUT = 5000; @@ -38,6 +23,7 @@ function NotificationTemplateListItem({ fetchTemplates, isSelected, onSelect, + rowIndex, i18n, }) { const recentNotifications = template.summary_fields?.recent_notifications; @@ -102,76 +88,62 @@ function NotificationTemplateListItem({ const labelId = `template-name-${template.id}`; return ( - - - - - - {template.name} - - , - - {status && } - , - - {i18n._(t`Type:`)}{' '} - {NOTIFICATION_TYPES[template.notification_type] || - template.notification_type} - , - ]} - /> - + + + + {template.name} + + + + {status && } + + + {NOTIFICATION_TYPES[template.notification_type] || + template.notification_type} + + + + + + - - - - {template.summary_fields.user_capabilities.edit ? ( - - - - ) : ( -
- )} - {template.summary_fields.user_capabilities.copy && ( - - - - )} - - - + + + + + + + ); } diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.test.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.test.jsx index 65f959522c..2bdcde637f 100644 --- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.test.jsx +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.test.jsx @@ -26,17 +26,21 @@ const template = { describe('', () => { test('should render template row', () => { const wrapper = mountWithContexts( - + + + + +
); - const cells = wrapper.find('DataListCell'); - expect(cells).toHaveLength(3); - expect(cells.at(0).text()).toEqual('Test Notification'); - expect(cells.at(1).text()).toEqual('Success'); - expect(cells.at(2).text()).toEqual('Type: Slack'); + const cells = wrapper.find('Td'); + expect(cells).toHaveLength(5); + expect(cells.at(1).text()).toEqual('Test Notification'); + expect(cells.at(2).text()).toEqual('Success'); + expect(cells.at(3).text()).toEqual('Slack'); }); test('should send test notification', async () => { @@ -45,10 +49,14 @@ describe('', () => { }); const wrapper = mountWithContexts( - + + + + +
); await act(async () => { wrapper @@ -59,8 +67,8 @@ describe('', () => { expect(NotificationTemplatesAPI.test).toHaveBeenCalledTimes(1); expect( wrapper - .find('DataListCell') - .at(1) + .find('Td') + .at(2) .text() ).toEqual('Running'); }); @@ -69,10 +77,14 @@ describe('', () => { NotificationTemplatesAPI.copy.mockResolvedValue(); const wrapper = mountWithContexts( - + + + + +
); await act(async () => @@ -86,10 +98,14 @@ describe('', () => { NotificationTemplatesAPI.copy.mockRejectedValue(new Error()); const wrapper = mountWithContexts( - + + + + +
); await act(async () => wrapper.find('Button[aria-label="Copy"]').prop('onClick')() @@ -101,18 +117,22 @@ describe('', () => { test('should not render copy button', async () => { const wrapper = mountWithContexts( - + + + + +
); expect(wrapper.find('CopyButton').length).toBe(0); }); From a481fc3cc9434caa64b0a9d9599db705d9ca3975 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Fri, 29 Jan 2021 15:41:20 -0800 Subject: [PATCH 009/397] convert instance group list to tables --- .../InstanceGroupList/InstanceGroupList.jsx | 24 ++- .../InstanceGroupList.test.jsx | 42 +++-- .../InstanceGroupListItem.jsx | 169 +++++------------- .../InstanceGroupListItem.test.jsx | 124 +++++++------ 4 files changed, 166 insertions(+), 193 deletions(-) diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx index 6b3b43f637..5e1994f640 100644 --- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx @@ -8,9 +8,11 @@ import { InstanceGroupsAPI } from '../../../api'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import useRequest, { useDeleteItems } from '../../../util/useRequest'; import useSelected from '../../../util/useSelected'; -import PaginatedDataList, { - ToolbarDeleteButton, -} from '../../../components/PaginatedDataList'; +import PaginatedTable, { + HeaderRow, + HeaderCell, +} from '../../../components/PaginatedTable'; +import { ToolbarDeleteButton } from '../../../components/PaginatedDataList'; import ErrorDetail from '../../../components/ErrorDetail'; import AlertModal from '../../../components/AlertModal'; import DatalistToolbar from '../../../components/DataListToolbar'; @@ -189,7 +191,7 @@ function InstanceGroupList({ i18n }) { <> - )} - renderItem={instanceGroup => ( + headerRow={ + + {i18n._(t`Name`)} + {i18n._(t`Type`)} + {i18n._(t`Running Jobs`)} + {i18n._(t`Total Jobs`)} + {i18n._(t`Instances`)} + {i18n._(t`Capacity`)} + {i18n._(t`Actions`)} + + } + renderRow={(instanceGroup, index) => ( handleSelect(instanceGroup)} isSelected={selected.some(row => row.id === instanceGroup.id)} + rowIndex={index} /> )} emptyStateControls={canAdd && addButton} diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.test.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.test.jsx index 335089e4bc..74f397dc1c 100644 --- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.test.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.test.jsx @@ -71,13 +71,19 @@ describe('', () => { await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0); wrapper - .find('input#select-instance-groups-1') + .find('.pf-c-table__check') + .first() + .find('input') .simulate('change', instanceGroups); wrapper.update(); - expect(wrapper.find('input#select-instance-groups-1').prop('checked')).toBe( - true - ); + expect( + wrapper + .find('.pf-c-table__check') + .first() + .find('input') + .prop('checked') + ).toBe(true); await act(async () => { wrapper.find('Button[aria-label="Delete"]').prop('onClick')(); @@ -102,16 +108,22 @@ describe('', () => { }); await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0); - const instanceGroupIndex = [1, 2, 3]; + const instanceGroupIndex = [0, 1, 2]; instanceGroupIndex.forEach(element => { wrapper - .find(`input#select-instance-groups-${element}`) + .find('.pf-c-table__check') + .at(element) + .find('input') .simulate('change', instanceGroups); wrapper.update(); expect( - wrapper.find(`input#select-instance-groups-${element}`).prop('checked') + wrapper + .find('.pf-c-table__check') + .at(element) + .find('input') + .prop('checked') ).toBe(true); }); @@ -159,11 +171,19 @@ describe('', () => { }); waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0); - wrapper.find('input#select-instance-groups-1').simulate('change', 'a'); + wrapper + .find('.pf-c-table__check') + .first() + .find('input') + .simulate('change', 'a'); wrapper.update(); - expect(wrapper.find('input#select-instance-groups-1').prop('checked')).toBe( - true - ); + expect( + wrapper + .find('.pf-c-table__check') + .first() + .find('input') + .prop('checked') + ).toBe(true); await act(async () => wrapper.find('Button[aria-label="Delete"]').prop('onClick')() diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.jsx index 670835c405..8bfcf05325 100644 --- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.jsx @@ -5,48 +5,18 @@ import { t } from '@lingui/macro'; import { Link } from 'react-router-dom'; import 'styled-components/macro'; import { - Badge as PFBadge, Button, - DataListAction as _DataListAction, - DataListCheck, - DataListItem, - DataListItemCells, - DataListItemRow, Label, Progress, ProgressMeasureLocation, ProgressSize, - Tooltip, } from '@patternfly/react-core'; +import { Tr, Td } from '@patternfly/react-table'; import { PencilAltIcon } from '@patternfly/react-icons'; import styled from 'styled-components'; - -import _DataListCell from '../../../components/DataListCell'; +import { ActionsTd, ActionItem } from '../../../components/PaginatedTable'; import { InstanceGroup } from '../../../types'; -const DataListCell = styled(_DataListCell)` - white-space: nowrap; -`; - -const Badge = styled(PFBadge)` - margin-left: 8px; -`; - -const ListGroup = styled.span` - margin-left: 12px; - - &:first-of-type { - margin-left: 0; - } -`; - -const DataListAction = styled(_DataListAction)` - align-items: center; - display: grid; - grid-gap: 16px; - grid-template-columns: 40px; -`; - const Unavailable = styled.span` color: var(--pf-global--danger-color--200); `; @@ -56,6 +26,7 @@ function InstanceGroupListItem({ detailUrl, isSelected, onSelect, + rowIndex, i18n, }) { const labelId = `check-action-${instanceGroup.id}`; @@ -104,98 +75,50 @@ function InstanceGroupListItem({ }; return ( - - - - - - - - {instanceGroup.name} - - - {verifyInstanceGroup(instanceGroup)} - , - - - {i18n._(t`Type`)} - - {isContainerGroup(instanceGroup) - ? i18n._(t`Container group`) - : i18n._(t`Instance group`)} - - , - - - {i18n._(t`Running jobs`)} - {instanceGroup.jobs_running} - - - {i18n._(t`Total jobs`)} - {instanceGroup.jobs_total} - - - {!instanceGroup.is_containerized ? ( - - {i18n._(t`Instances`)} - {instanceGroup.instances} - - ) : null} - , - - - {usedCapacity(instanceGroup)} - , - ]} - /> - + + + + {instanceGroup.name} + {verifyInstanceGroup(instanceGroup)} + + + + {isContainerGroup(instanceGroup) + ? i18n._(t`Container group`) + : i18n._(t`Instance group`)} + + {instanceGroup.jobs_running} + {instanceGroup.jobs_total} + {instanceGroup.instances} + {usedCapacity(instanceGroup)} + + - {instanceGroup.summary_fields.user_capabilities.edit && ( - - - - )} - - - + + + + ); } InstanceGroupListItem.prototype = { diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.test.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.test.jsx index 9c819dd964..0f22a4b6d7 100644 --- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.test.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.test.jsx @@ -47,12 +47,16 @@ describe('', () => { test('should mount successfully', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + + {}} + /> + +
); }); expect(wrapper.find('InstanceGroupListItem').length).toBe(1); @@ -61,73 +65,81 @@ describe('', () => { test('should render the proper data instance group', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + + {}} + /> + +
); }); expect( - wrapper.find('PFDataListCell[aria-label="instance group name"]').text() + wrapper + .find('Td') + .at(1) + .text() ).toBe('Foo'); expect(wrapper.find('Progress').prop('value')).toBe(40); expect( - wrapper.find('PFDataListCell[aria-label="instance group type"]').text() - ).toBe('TypeInstance group'); + wrapper + .find('Td') + .at(2) + .text() + ).toBe('Instance group'); expect(wrapper.find('PencilAltIcon').length).toBe(1); - expect(wrapper.find('input#select-instance-groups-1').prop('checked')).toBe( - false + expect(wrapper.find('.pf-c-table__check input').prop('checked')).toBe( + undefined ); }); test('should render the proper data container group', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + + {}} + /> + +
); }); expect( - wrapper.find('PFDataListCell[aria-label="instance group name"]').text() + wrapper + .find('Td') + .at(1) + .text() ).toBe('Bar'); expect( - wrapper.find('PFDataListCell[aria-label="instance group type"]').text() - ).toBe('TypeContainer group'); + wrapper + .find('Td') + .at(2) + .text() + ).toBe('Container group'); expect(wrapper.find('PencilAltIcon').length).toBe(0); }); - test('should be checked', async () => { - await act(async () => { - wrapper = mountWithContexts( - {}} - /> - ); - }); - expect(wrapper.find('input#select-instance-groups-1').prop('checked')).toBe( - true - ); - }); - test('edit button shown to users with edit capabilities', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + + {}} + /> + +
); }); @@ -137,12 +149,16 @@ describe('', () => { test('edit button hidden from users without edit capabilities', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + + {}} + /> + +
); }); From 87cf7971532a27f6cd6c8bce9c5f812240656955 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Fri, 29 Jan 2021 15:52:16 -0800 Subject: [PATCH 010/397] convert application list to tables --- .../ApplicationsList/ApplicationList.test.jsx | 32 +++-- .../ApplicationsList/ApplicationListItem.jsx | 125 ++++++------------ .../ApplicationListItem.test.jsx | 60 ++++----- .../ApplicationsList/ApplicationsList.jsx | 39 +++--- 4 files changed, 113 insertions(+), 143 deletions(-) diff --git a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationList.test.jsx b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationList.test.jsx index 7824ea5ed5..6832d07021 100644 --- a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationList.test.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationList.test.jsx @@ -48,6 +48,7 @@ describe('', () => { }); await waitForElement(wrapper, 'ApplicationsList', el => el.length > 0); }); + test('should have data fetched and render 2 rows', async () => { ApplicationsAPI.read.mockResolvedValue(applications); ApplicationsAPI.readOptions.mockResolvedValue(options); @@ -69,14 +70,20 @@ describe('', () => { waitForElement(wrapper, 'ApplicationsList', el => el.length > 0); wrapper - .find('input#select-application-1') + .find('.pf-c-table__check') + .first() + .find('input') .simulate('change', applications.data.results[0]); wrapper.update(); - expect(wrapper.find('input#select-application-1').prop('checked')).toBe( - true - ); + expect( + wrapper + .find('.pf-c-table__check') + .first() + .find('input') + .prop('checked') + ).toBe(true); await act(async () => wrapper.find('Button[aria-label="Delete"]').prop('onClick')() ); @@ -131,13 +138,21 @@ describe('', () => { }); waitForElement(wrapper, 'ApplicationsList', el => el.length > 0); - wrapper.find('input#select-application-1').simulate('change', 'a'); + wrapper + .find('.pf-c-table__check') + .first() + .find('input') + .simulate('change', 'a'); wrapper.update(); - expect(wrapper.find('input#select-application-1').prop('checked')).toBe( - true - ); + expect( + wrapper + .find('.pf-c-table__check') + .first() + .find('input') + .prop('checked') + ).toBe(true); await act(async () => wrapper.find('Button[aria-label="Delete"]').prop('onClick')() ); @@ -163,6 +178,7 @@ describe('', () => { waitForElement(wrapper, 'ApplicationsList', el => el.length > 0); expect(wrapper.find('ToolbarAddButton').length).toBe(0); }); + test('should not render edit button for first list item', async () => { applications.data.results[0].summary_fields.user_capabilities.edit = false; ApplicationsAPI.read.mockResolvedValue(applications); diff --git a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.jsx b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.jsx index 1a0f5c9f4e..0d8a10b98e 100644 --- a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.jsx @@ -1,104 +1,65 @@ import React from 'react'; import { string, bool, func } from 'prop-types'; import { withI18n } from '@lingui/react'; -import { - Button, - DataListAction as _DataListAction, - DataListCheck, - DataListItem, - DataListItemCells, - DataListItemRow, - Tooltip, -} from '@patternfly/react-core'; - +import { Button } from '@patternfly/react-core'; +import { Tr, Td } from '@patternfly/react-table'; import { t } from '@lingui/macro'; import { Link } from 'react-router-dom'; -import styled from 'styled-components'; import { PencilAltIcon } from '@patternfly/react-icons'; +import { ActionsTd, ActionItem } from '../../../components/PaginatedTable'; import { formatDateString } from '../../../util/dates'; import { Application } from '../../../types'; -import DataListCell from '../../../components/DataListCell'; - -const DataListAction = styled(_DataListAction)` - align-items: center; - display: grid; - grid-gap: 16px; - grid-template-columns: 40px; -`; - -const Label = styled.b` - margin-right: 20px; -`; function ApplicationListItem({ application, isSelected, onSelect, detailUrl, + rowIndex, i18n, }) { const labelId = `check-action-${application.id}`; return ( - - - - - - {application.name} - - , - - - {application.summary_fields.organization.name} - - , - - - {formatDateString(application.modified)} - , - ]} - /> - + + + + {application.name} + + + + - {application.summary_fields.user_capabilities.edit ? ( - - - - ) : ( - '' - )} - - - + {application.summary_fields.organization.name} + + + + {formatDateString(application.modified)} + + + + + + + ); } diff --git a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.test.jsx b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.test.jsx index 0a53dd4cd8..510c89ff28 100644 --- a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.test.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationListItem.test.jsx @@ -18,12 +18,16 @@ describe('', () => { test('should mount successfully', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + + {}} + /> + +
); }); expect(wrapper.find('ApplicationListItem').length).toBe(1); @@ -31,38 +35,30 @@ describe('', () => { test('should render the proper data', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + + {}} + /> + +
); }); expect( - wrapper.find('DataListCell[aria-label="application name"]').text() + wrapper + .find('Td') + .at(1) + .text() ).toBe('Foo'); expect( - wrapper.find('DataListCell[aria-label="organization name"]').text() + wrapper + .find('Td') + .at(2) + .text() ).toBe('Organization'); - expect(wrapper.find('input#select-application-1').prop('checked')).toBe( - false - ); expect(wrapper.find('PencilAltIcon').length).toBe(1); }); - test('should be checked', async () => { - await act(async () => { - wrapper = mountWithContexts( - {}} - /> - ); - }); - expect(wrapper.find('input#select-application-1').prop('checked')).toBe( - true - ); - }); }); diff --git a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx index b35ecc7d68..5ed5e092da 100644 --- a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx @@ -11,7 +11,11 @@ import AlertModal from '../../../components/AlertModal'; import DatalistToolbar from '../../../components/DataListToolbar'; import { ApplicationsAPI } from '../../../api'; -import PaginatedDataList, { +import PaginatedTable, { + HeaderRow, + HeaderCell, +} from '../../../components/PaginatedTable'; +import { ToolbarDeleteButton, ToolbarAddButton, } from '../../../components/PaginatedDataList'; @@ -104,7 +108,7 @@ function ApplicationsList({ i18n }) { <> - ( @@ -170,7 +156,17 @@ function ApplicationsList({ i18n }) { ]} /> )} - renderItem={application => ( + headerRow={ + + {i18n._(t`Name`)} + + {i18n._(t`Organization`)} + + {i18n._(t`Last Modified`)} + {i18n._(t`Actions`)} + + } + renderRow={(application, index) => ( handleSelect(application)} isSelected={selected.some(row => row.id === application.id)} + rowIndex={index} /> )} emptyStateControls={ From eb66a03a304041345c03d3c934ab8513308950dc Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Thu, 4 Feb 2021 14:11:36 -0800 Subject: [PATCH 011/397] fix duplicate tooltip on notification template edit button --- .../NotificationTemplateListItem.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.jsx index f3d99b76db..93d1f82999 100644 --- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.jsx +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateList/NotificationTemplateListItem.jsx @@ -133,7 +133,10 @@ function NotificationTemplateListItem({ - + Date: Thu, 4 Feb 2021 14:46:29 -0800 Subject: [PATCH 012/397] convert NotificationList to tables --- .../NotificationList/NotificationList.jsx | 26 +- .../NotificationList.test.jsx | 4 - .../NotificationList/NotificationListItem.jsx | 111 +-- .../NotificationListItem.test.jsx | 227 +++--- .../NotificationListItem.test.jsx.snap | 758 +++++++++--------- .../components/PaginatedTable/ActionItem.jsx | 10 +- .../components/PaginatedTable/HeaderRow.jsx | 13 +- 7 files changed, 607 insertions(+), 542 deletions(-) diff --git a/awx/ui_next/src/components/NotificationList/NotificationList.jsx b/awx/ui_next/src/components/NotificationList/NotificationList.jsx index 4bdba17027..4422dc8c01 100644 --- a/awx/ui_next/src/components/NotificationList/NotificationList.jsx +++ b/awx/ui_next/src/components/NotificationList/NotificationList.jsx @@ -6,7 +6,7 @@ import { t } from '@lingui/macro'; import AlertModal from '../AlertModal'; import ErrorDetail from '../ErrorDetail'; import NotificationListItem from './NotificationListItem'; -import PaginatedDataList from '../PaginatedDataList'; +import PaginatedTable, { HeaderRow, HeaderCell } from '../PaginatedTable'; import { getQSConfig, parseQueryString } from '../../util/qs'; import useRequest from '../../util/useRequest'; import { NotificationTemplatesAPI } from '../../api'; @@ -169,7 +169,7 @@ function NotificationList({ return ( <> - ( + headerRow={ + + {i18n._(t`Name`)} + + {i18n._(t`Type`)} + + {i18n._(t`Options`)} + + } + renderRow={(notification, index) => ( )} /> diff --git a/awx/ui_next/src/components/NotificationList/NotificationList.test.jsx b/awx/ui_next/src/components/NotificationList/NotificationList.test.jsx index 071eb45e9f..720b2f15c0 100644 --- a/awx/ui_next/src/components/NotificationList/NotificationList.test.jsx +++ b/awx/ui_next/src/components/NotificationList/NotificationList.test.jsx @@ -87,10 +87,6 @@ describe('', () => { wrapper.unmount(); }); - test('initially renders succesfully', () => { - expect(wrapper.find('PaginatedDataList')).toHaveLength(1); - }); - test('should render list fetched of items', () => { expect(NotificationTemplatesAPI.read).toHaveBeenCalled(); expect(NotificationTemplatesAPI.readOptions).toHaveBeenCalled(); diff --git a/awx/ui_next/src/components/NotificationList/NotificationListItem.jsx b/awx/ui_next/src/components/NotificationList/NotificationListItem.jsx index 8419db0977..5b0fc2fad7 100644 --- a/awx/ui_next/src/components/NotificationList/NotificationListItem.jsx +++ b/awx/ui_next/src/components/NotificationList/NotificationListItem.jsx @@ -3,25 +3,9 @@ import { shape, number, string, bool, func } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Link } from 'react-router-dom'; -import { - DataListAction as _DataListAction, - DataListItem, - DataListItemCells, - DataListItemRow, - Switch, -} from '@patternfly/react-core'; -import styled from 'styled-components'; -import DataListCell from '../DataListCell'; - -const DataListAction = styled(_DataListAction)` - align-items: center; - display: grid; - grid-gap: 16px; - grid-template-columns: ${props => `repeat(${props.columns}, max-content)`}; -`; -const Label = styled.b` - margin-right: 20px; -`; +import { Switch } from '@patternfly/react-core'; +import { Tr, Td } from '@patternfly/react-table'; +import { ActionsTd, ActionItem } from '../PaginatedTable'; function NotificationListItem({ canToggleNotifications, @@ -37,54 +21,37 @@ function NotificationListItem({ showApprovalsToggle, }) { return ( - - - - - - {notification.name} - - - , - - - {typeLabels[notification.notification_type]} - , - ]} - /> - - {showApprovalsToggle && ( - - toggleNotification( - notification.id, - approvalsTurnedOn, - 'approvals' - ) - } - aria-label={i18n._(t`Toggle notification approvals`)} - /> - )} + + + + {notification.name} + + + + {typeLabels[notification.notification_type]} + + + + + toggleNotification( + notification.id, + approvalsTurnedOn, + 'approvals' + ) + } + aria-label={i18n._(t`Toggle notification approvals`)} + /> + + + + + + - - - + + + ); } diff --git a/awx/ui_next/src/components/NotificationList/NotificationListItem.test.jsx b/awx/ui_next/src/components/NotificationList/NotificationListItem.test.jsx index 22afb5373b..983525db04 100644 --- a/awx/ui_next/src/components/NotificationList/NotificationListItem.test.jsx +++ b/awx/ui_next/src/components/NotificationList/NotificationListItem.test.jsx @@ -30,13 +30,17 @@ describe('', () => { test('initially renders succesfully and displays correct label', () => { wrapper = mountWithContexts( - + + + + +
); expect(wrapper.find('NotificationListItem')).toMatchSnapshot(); expect(wrapper.find('Switch').length).toBe(3); @@ -44,46 +48,55 @@ describe('', () => { test('shows approvals toggle when configured', () => { wrapper = mountWithContexts( - + + + + +
); expect(wrapper.find('Switch').length).toBe(4); }); - test('displays correct label in correct column', () => { + test('displays correct type', () => { wrapper = mountWithContexts( - + + + + +
); - const typeCell = wrapper - .find('DataListCell') - .at(1) - .find('div'); + const typeCell = wrapper.find('Td').at(1); expect(typeCell.text()).toContain('Slack'); }); test('handles approvals click when toggle is on', () => { wrapper = mountWithContexts( - + + + + +
); wrapper .find('Switch[aria-label="Toggle notification approvals"]') @@ -95,15 +108,19 @@ describe('', () => { test('handles approvals click when toggle is off', () => { wrapper = mountWithContexts( - + + + + +
); wrapper .find('Switch[aria-label="Toggle notification approvals"]') @@ -114,14 +131,18 @@ describe('', () => { test('handles started click when toggle is on', () => { wrapper = mountWithContexts( - + + + + +
); wrapper .find('Switch[aria-label="Toggle notification start"]') @@ -132,14 +153,18 @@ describe('', () => { test('handles started click when toggle is off', () => { wrapper = mountWithContexts( - + + + + +
); wrapper .find('Switch[aria-label="Toggle notification start"]') @@ -150,14 +175,18 @@ describe('', () => { test('handles success click when toggle is on', () => { wrapper = mountWithContexts( - + + + + +
); wrapper .find('Switch[aria-label="Toggle notification success"]') @@ -168,14 +197,18 @@ describe('', () => { test('handles success click when toggle is off', () => { wrapper = mountWithContexts( - + + + + +
); wrapper .find('Switch[aria-label="Toggle notification success"]') @@ -186,14 +219,18 @@ describe('', () => { test('handles error click when toggle is on', () => { wrapper = mountWithContexts( - + + + + +
); wrapper .find('Switch[aria-label="Toggle notification failure"]') @@ -204,14 +241,18 @@ describe('', () => { test('handles error click when toggle is off', () => { wrapper = mountWithContexts( - + + + + +
); wrapper .find('Switch[aria-label="Toggle notification failure"]') diff --git a/awx/ui_next/src/components/NotificationList/__snapshots__/NotificationListItem.test.jsx.snap b/awx/ui_next/src/components/NotificationList/__snapshots__/NotificationListItem.test.jsx.snap index 7303fdbd7d..eaf045d78d 100644 --- a/awx/ui_next/src/components/NotificationList/__snapshots__/NotificationListItem.test.jsx.snap +++ b/awx/ui_next/src/components/NotificationList/__snapshots__/NotificationListItem.test.jsx.snap @@ -24,398 +24,442 @@ exports[` initially renders succe } } > - -
  • - -
  • -
    + + + + +
    `; diff --git a/awx/ui_next/src/components/PaginatedTable/ActionItem.jsx b/awx/ui_next/src/components/PaginatedTable/ActionItem.jsx index f9c423fee3..5d4f22d12f 100644 --- a/awx/ui_next/src/components/PaginatedTable/ActionItem.jsx +++ b/awx/ui_next/src/components/PaginatedTable/ActionItem.jsx @@ -13,9 +13,13 @@ export default function ActionItem({ column, tooltip, visible, children }) { grid-column: ${column}; `} > - -
    {children}
    -
    + {tooltip ? ( + +
    {children}
    +
    + ) : ( + children + )}
    ); } diff --git a/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx b/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx index cec9a984ef..a7b076da57 100644 --- a/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx +++ b/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx @@ -13,7 +13,12 @@ const Th = styled(PFTh)` --pf-c-table--cell--Overflow: initial; `; -export default function HeaderRow({ qsConfig, isExpandable, children }) { +export default function HeaderRow({ + qsConfig, + isExpandable, + isSelectable, + children, +}) { const location = useLocation(); const history = useHistory(); @@ -49,7 +54,7 @@ export default function HeaderRow({ qsConfig, isExpandable, children }) { {isExpandable && } - + {isSelectable && } {React.Children.map( children, child => @@ -66,6 +71,10 @@ export default function HeaderRow({ qsConfig, isExpandable, children }) { ); } +HeaderRow.defaultProps = { + isSelectable: true, +}; + export function HeaderCell({ sortKey, onSort, From 1c61fafbc7c695f2af2370715fcd0185e778101f Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 16 Feb 2021 16:25:19 -0500 Subject: [PATCH 013/397] Add pending approvals badge to application header --- .../AppContainer/PageHeaderToolbar.jsx | 70 ++++++++++- .../AppContainer/PageHeaderToolbar.test.jsx | 69 ++++++++--- .../AppContainer/useWsPendingApprovalCount.js | 46 +++++++ .../useWsPendingApprovalCount.test.jsx | 117 ++++++++++++++++++ 4 files changed, 284 insertions(+), 18 deletions(-) create mode 100644 awx/ui_next/src/components/AppContainer/useWsPendingApprovalCount.js create mode 100644 awx/ui_next/src/components/AppContainer/useWsPendingApprovalCount.test.jsx diff --git a/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.jsx b/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.jsx index 1b8bb0c7e4..1238665601 100644 --- a/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.jsx +++ b/awx/ui_next/src/components/AppContainer/PageHeaderToolbar.jsx @@ -1,8 +1,11 @@ -import React, { useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; import { + Badge, Dropdown, DropdownItem, DropdownToggle, @@ -12,7 +15,25 @@ import { PageHeaderToolsItem, Tooltip, } from '@patternfly/react-core'; -import { QuestionCircleIcon, UserIcon } from '@patternfly/react-icons'; +import { + BellIcon, + QuestionCircleIcon, + UserIcon, +} from '@patternfly/react-icons'; +import { WorkflowApprovalsAPI } from '../../api'; +import useRequest from '../../util/useRequest'; +import useWsPendingApprovalCount from './useWsPendingApprovalCount'; + +const PendingWorkflowApprovals = styled.div` + display: flex; + align-items: center; + padding: 10px; + margin-right: 10px; +`; + +const PendingWorkflowApprovalBadge = styled(Badge)` + margin-left: 10px; +`; const DOCLINK = 'https://docs.ansible.com/ansible-tower/latest/html/userguide/index.html'; @@ -27,6 +48,31 @@ function PageHeaderToolbar({ const [isHelpOpen, setIsHelpOpen] = useState(false); const [isUserOpen, setIsUserOpen] = useState(false); + const { + request: fetchPendingApprovalCount, + result: pendingApprovals, + } = useRequest( + useCallback(async () => { + const { + data: { count }, + } = await WorkflowApprovalsAPI.read({ + status: 'pending', + page_size: 1, + }); + return count; + }, []), + 0 + ); + + const pendingApprovalsCount = useWsPendingApprovalCount( + pendingApprovals, + fetchPendingApprovalCount + ); + + useEffect(() => { + fetchPendingApprovalCount(); + }, [fetchPendingApprovalCount]); + const handleHelpSelect = () => { setIsHelpOpen(!isHelpOpen); }; @@ -37,7 +83,25 @@ function PageHeaderToolbar({ return ( - {i18n._(t`Info`)}}> + + + + + + + {pendingApprovalsCount} + + + + + + {i18n._(t`Info`)}}> { const pageHelpDropdownSelector = 'Dropdown QuestionCircleIcon'; @@ -8,26 +14,39 @@ describe('PageHeaderToolbar', () => { const onAboutClick = jest.fn(); const onLogoutClick = jest.fn(); - test('expected content is rendered on initialization', () => { - const wrapper = mountWithContexts( - - ); + afterEach(() => { + wrapper.unmount(); + }); + test('expected content is rendered on initialization', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + + expect( + wrapper.find( + 'Link[to="/workflow_approvals?workflow_approvals.status=pending"]' + ) + ).toHaveLength(1); expect(wrapper.find(pageHelpDropdownSelector)).toHaveLength(1); expect(wrapper.find(pageUserDropdownSelector)).toHaveLength(1); }); - test('dropdowns have expected items and callbacks', () => { - const wrapper = mountWithContexts( - - ); + test('dropdowns have expected items and callbacks', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); expect(wrapper.find('DropdownItem')).toHaveLength(0); wrapper.find(pageHelpDropdownSelector).simulate('click'); expect(wrapper.find('DropdownItem')).toHaveLength(2); @@ -48,4 +67,24 @@ describe('PageHeaderToolbar', () => { logout.simulate('click'); expect(onLogoutClick).toHaveBeenCalled(); }); + + test('pending workflow approvals count set correctly', async () => { + WorkflowApprovalsAPI.read.mockResolvedValueOnce({ + data: { + count: 20, + }, + }); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + + expect( + wrapper.find('Badge#toolbar-workflow-approval-badge').text() + ).toEqual('20'); + }); }); diff --git a/awx/ui_next/src/components/AppContainer/useWsPendingApprovalCount.js b/awx/ui_next/src/components/AppContainer/useWsPendingApprovalCount.js new file mode 100644 index 0000000000..d6b1edde4a --- /dev/null +++ b/awx/ui_next/src/components/AppContainer/useWsPendingApprovalCount.js @@ -0,0 +1,46 @@ +import { useState, useEffect } from 'react'; +import useWebsocket from '../../util/useWebsocket'; +import useThrottle from '../../util/useThrottle'; + +export default function useWsPendingApprovalCount( + initialCount, + fetchApprovalsCount +) { + const [pendingApprovalCount, setPendingApprovalCount] = useState( + initialCount + ); + const [reloadCount, setReloadCount] = useState(false); + const throttledFetch = useThrottle(reloadCount, 1000); + const lastMessage = useWebsocket({ + jobs: ['status_changed'], + control: ['limit_reached_1'], + }); + + useEffect(() => { + setPendingApprovalCount(initialCount); + }, [initialCount]); + + useEffect( + function reloadTheCount() { + (async () => { + if (!throttledFetch) { + return; + } + setReloadCount(false); + fetchApprovalsCount(); + })(); + }, + [throttledFetch, fetchApprovalsCount] + ); + + useEffect( + function processWsMessage() { + if (lastMessage?.type === 'workflow_approval') { + setReloadCount(true); + } + }, + [lastMessage] + ); + + return pendingApprovalCount; +} diff --git a/awx/ui_next/src/components/AppContainer/useWsPendingApprovalCount.test.jsx b/awx/ui_next/src/components/AppContainer/useWsPendingApprovalCount.test.jsx new file mode 100644 index 0000000000..4e067d6c9c --- /dev/null +++ b/awx/ui_next/src/components/AppContainer/useWsPendingApprovalCount.test.jsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import WS from 'jest-websocket-mock'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import useWsPendingApprovalCount from './useWsPendingApprovalCount'; + +/* + Jest mock timers don’t play well with jest-websocket-mock, + so we'll stub out throttling to resolve immediately +*/ +jest.mock('../../util/useThrottle', () => ({ + __esModule: true, + default: jest.fn(val => val), +})); + +function TestInner() { + return
    ; +} +function Test({ initialCount, fetchApprovalsCount }) { + const updatedWorkflowApprovals = useWsPendingApprovalCount( + initialCount, + fetchApprovalsCount + ); + return ; +} + +describe('useWsPendingApprovalCount hook', () => { + let debug; + let wrapper; + beforeEach(() => { + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; + }); + + afterEach(() => { + global.console.debug = debug; + WS.clean(); + }); + + test('should return workflow approval pending count', () => { + wrapper = mountWithContexts( + {}} /> + ); + + expect(wrapper.find('TestInner').prop('initialCount')).toEqual(2); + }); + + test('should establish websocket connection', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('ws://localhost/websocket/'); + + await act(async () => { + wrapper = mountWithContexts( + {}} /> + ); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + }); + + test('should refetch count after approval status changes', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('ws://localhost/websocket/'); + const fetchApprovalsCount = jest.fn(() => []); + await act(async () => { + wrapper = await mountWithContexts( + + ); + }); + + await mockServer.connected; + await act(async () => { + mockServer.send( + JSON.stringify({ + unified_job_id: 2, + type: 'workflow_approval', + status: 'pending', + }) + ); + }); + + expect(fetchApprovalsCount).toHaveBeenCalledTimes(1); + }); + + test('should not refetch when message is not workflow approval', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('ws://localhost/websocket/'); + const fetchApprovalsCount = jest.fn(() => []); + await act(async () => { + wrapper = await mountWithContexts( + + ); + }); + + await mockServer.connected; + await act(async () => { + mockServer.send( + JSON.stringify({ + unified_job_id: 1, + type: 'job', + status: 'successful', + }) + ); + }); + + expect(fetchApprovalsCount).toHaveBeenCalledTimes(0); + }); +}); From 40c6b346c13c183d56aaee9a75da1bce44e67cc0 Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 18 Feb 2021 09:43:44 -0500 Subject: [PATCH 014/397] Adds note to changelog about pending workflow approval count --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b8427c4c..d10327b2fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ This is a list of high-level changes for each release of AWX. A full list of com - Fixed a bug where launch prompt inputs were unexpectedly deposited in the url: https://github.com/ansible/awx/pull/9231 - Playbook, credential type, and inventory file inputs now support type-ahead and manual type-in! https://github.com/ansible/awx/pull/9120 - Added ability to relaunch against failed hosts: https://github.com/ansible/awx/pull/9225 +- Added pending workflow approval count to the application header https://github.com/ansible/awx/pull/9334 # 17.0.1 (January 26, 2021) - Fixed pgdocker directory permissions issue with Local Docker installer: https://github.com/ansible/awx/pull/9152 From 0e6c14e707e41accf8b0cbbdd0f9f41f008e78b6 Mon Sep 17 00:00:00 2001 From: Elijah DeLee Date: Thu, 18 Feb 2021 17:56:01 -0500 Subject: [PATCH 015/397] create performance logger to log api response time this middleware allready existed, and we were trying to log this data but it was not working. Hope is these logs will be able to be shipped via external logging and we could use kibana to track response time of different endpoints --- awx/main/middleware.py | 5 ++++- awx/settings/defaults.py | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/awx/main/middleware.py b/awx/main/middleware.py index 8bfd273811..2d509c9c61 100644 --- a/awx/main/middleware.py +++ b/awx/main/middleware.py @@ -45,7 +45,10 @@ class TimingMiddleware(threading.local, MiddlewareMixin): response['X-API-Total-Time'] = '%0.3fs' % total_time if settings.AWX_REQUEST_PROFILE: response['X-API-Profile-File'] = self.prof.stop() - perf_logger.info('api response times', extra=dict(python_objects=dict(request=request, response=response))) + perf_logger.info( + f'request: {request}, response_time: {response["X-API-Total-Time"]}', + extra=dict(python_objects=dict(request=request, response=response, X_API_TOTAL_TIME=response["X-API-Total-Time"])) + ) return response diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index af1590862c..f995ad6000 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -1044,6 +1044,11 @@ LOGGING = { 'level': 'INFO', 'propagate': False }, + 'awx.analytics.performance': { + 'handlers': ['console', 'file', 'tower_warnings', 'external_logger'], + 'level': 'DEBUG', + 'propagate': False + }, 'awx.analytics.job_lifecycle': { 'handlers': ['console', 'job_lifecycle'], 'level': 'DEBUG', From 61c0beccffbad3ac5ce53049344c1475a1f13479 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 26 Jan 2021 14:58:52 -0500 Subject: [PATCH 016/397] Updates props being passed to Schedules to more accuratly reflect what they are --- .../src/api/models/InventorySources.js | 1 + awx/ui_next/src/api/models/JobTemplates.js | 1 + awx/ui_next/src/api/models/Projects.js | 1 + awx/ui_next/src/api/models/Schedules.js | 13 +++++ .../src/api/models/WorkflowJobTemplates.js | 1 + .../src/components/Schedule/Schedule.jsx | 57 +++++++++---------- .../src/components/Schedule/Schedule.test.jsx | 5 +- .../Schedule/ScheduleAdd/ScheduleAdd.jsx | 11 ++-- .../ScheduleDetail/ScheduleDetail.jsx | 14 +++++ .../Schedule/ScheduleEdit/ScheduleEdit.jsx | 2 +- .../src/components/Schedule/Schedules.jsx | 11 ++-- .../InventorySource/InventorySource.jsx | 9 +-- awx/ui_next/src/screens/Project/Project.jsx | 8 +-- awx/ui_next/src/screens/Template/Template.jsx | 8 +-- .../screens/Template/WorkflowJobTemplate.jsx | 8 +-- 15 files changed, 78 insertions(+), 72 deletions(-) diff --git a/awx/ui_next/src/api/models/InventorySources.js b/awx/ui_next/src/api/models/InventorySources.js index 8d20076ba8..baa2a85cb0 100644 --- a/awx/ui_next/src/api/models/InventorySources.js +++ b/awx/ui_next/src/api/models/InventorySources.js @@ -10,6 +10,7 @@ class InventorySources extends LaunchUpdateMixin( super(http); this.baseUrl = '/api/v2/inventory_sources/'; + this.createSchedule = this.createSchedule.bind(this); this.createSyncStart = this.createSyncStart.bind(this); this.destroyGroups = this.destroyGroups.bind(this); this.destroyHosts = this.destroyHosts.bind(this); diff --git a/awx/ui_next/src/api/models/JobTemplates.js b/awx/ui_next/src/api/models/JobTemplates.js index 44281f1511..da0af7cff5 100644 --- a/awx/ui_next/src/api/models/JobTemplates.js +++ b/awx/ui_next/src/api/models/JobTemplates.js @@ -10,6 +10,7 @@ class JobTemplates extends SchedulesMixin( super(http); this.baseUrl = '/api/v2/job_templates/'; + this.createSchedule = this.createSchedule.bind(this); this.launch = this.launch.bind(this); this.readLaunch = this.readLaunch.bind(this); this.associateLabel = this.associateLabel.bind(this); diff --git a/awx/ui_next/src/api/models/Projects.js b/awx/ui_next/src/api/models/Projects.js index 38879a2bc2..1810bb33e5 100644 --- a/awx/ui_next/src/api/models/Projects.js +++ b/awx/ui_next/src/api/models/Projects.js @@ -16,6 +16,7 @@ class Projects extends SchedulesMixin( this.readPlaybooks = this.readPlaybooks.bind(this); this.readSync = this.readSync.bind(this); this.sync = this.sync.bind(this); + this.createSchedule = this.createSchedule.bind(this); } readAccessList(id, params) { diff --git a/awx/ui_next/src/api/models/Schedules.js b/awx/ui_next/src/api/models/Schedules.js index 7f20e992ae..14b982ba0d 100644 --- a/awx/ui_next/src/api/models/Schedules.js +++ b/awx/ui_next/src/api/models/Schedules.js @@ -14,6 +14,19 @@ class Schedules extends Base { return this.http.get(`${this.baseUrl}${resourceId}/credentials/`, params); } + associateCredential(resourceId, credentialId) { + return this.http.post(`${this.baseUrl}${resourceId}/credentials/`, { + id: credentialId, + }); + } + + disassociateCredential(resourceId, credentialId) { + return this.http.post(`${this.baseUrl}${resourceId}/credentials/`, { + id: credentialId, + disassociate: true, + }); + } + readZoneInfo() { return this.http.get(`${this.baseUrl}zoneinfo/`); } diff --git a/awx/ui_next/src/api/models/WorkflowJobTemplates.js b/awx/ui_next/src/api/models/WorkflowJobTemplates.js index beed5be9ad..9f868534b6 100644 --- a/awx/ui_next/src/api/models/WorkflowJobTemplates.js +++ b/awx/ui_next/src/api/models/WorkflowJobTemplates.js @@ -6,6 +6,7 @@ class WorkflowJobTemplates extends SchedulesMixin(NotificationsMixin(Base)) { constructor(http) { super(http); this.baseUrl = '/api/v2/workflow_job_templates/'; + this.createSchedule = this.createSchedule.bind(this); } readWebhookKey(id) { diff --git a/awx/ui_next/src/components/Schedule/Schedule.jsx b/awx/ui_next/src/components/Schedule/Schedule.jsx index ffa28dd35f..201841048c 100644 --- a/awx/ui_next/src/components/Schedule/Schedule.jsx +++ b/awx/ui_next/src/components/Schedule/Schedule.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { t } from '@lingui/macro'; import { withI18n } from '@lingui/react'; @@ -17,37 +17,39 @@ import ContentLoading from '../ContentLoading'; import ScheduleDetail from './ScheduleDetail'; import ScheduleEdit from './ScheduleEdit'; import { SchedulesAPI } from '../../api'; +import useRequest from '../../util/useRequest'; -function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) { - const [schedule, setSchedule] = useState(null); - const [contentLoading, setContentLoading] = useState(true); - const [contentError, setContentError] = useState(null); +function Schedule({ i18n, setBreadcrumb, resource }) { const { scheduleId } = useParams(); - const location = useLocation(); - const { pathname } = location; + + const { pathname } = useLocation(); + const pathRoot = pathname.substr(0, pathname.indexOf('schedules')); - useEffect(() => { - const loadData = async () => { - try { - const { data } = await SchedulesAPI.readDetail(scheduleId); - setSchedule(data); - } catch (err) { - setContentError(err); - } finally { - setContentLoading(false); - } - }; + const { + isLoading: contentLoading, + error: contentError, + request: loadData, + result: schedule, + } = useRequest( + useCallback(async () => { + const { data } = await SchedulesAPI.readDetail(scheduleId); + return data; + }, [scheduleId]), + null + ); + + useEffect(() => { loadData(); - }, [location.pathname, scheduleId]); + }, [loadData, pathname]); useEffect(() => { if (schedule) { - setBreadcrumb(unifiedJobTemplate, schedule); + setBreadcrumb(resource, schedule); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [schedule, unifiedJobTemplate]); + }, [schedule, resource]); const tabsArray = [ { name: ( @@ -71,8 +73,8 @@ function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) { } if ( - schedule.summary_fields.unified_job_template.id !== - parseInt(unifiedJobTemplate.id, 10) + schedule?.summary_fields.unified_job_template.id !== + parseInt(resource.id, 10) ) { return ( @@ -89,10 +91,7 @@ function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) { let showCardHeader = true; - if ( - !location.pathname.includes('schedules/') || - location.pathname.endsWith('edit') - ) { + if (!pathname.includes('schedules/') || pathname.endsWith('edit')) { showCardHeader = false; } return ( @@ -106,7 +105,7 @@ function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) { /> {schedule && [ - + , - {unifiedJobTemplate && ( + {resource && ( {i18n._(t`View Details`)} )} diff --git a/awx/ui_next/src/components/Schedule/Schedule.test.jsx b/awx/ui_next/src/components/Schedule/Schedule.test.jsx index e3c394cc95..280c6af6bb 100644 --- a/awx/ui_next/src/components/Schedule/Schedule.test.jsx +++ b/awx/ui_next/src/components/Schedule/Schedule.test.jsx @@ -93,10 +93,7 @@ describe('', () => { ( - {}} - unifiedJobTemplate={unifiedJobTemplate} - /> + {}} resource={unifiedJobTemplate} /> )} />, { diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx index 7285e760a2..e6a9b81699 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx @@ -8,7 +8,7 @@ import { CardBody } from '../../Card'; import buildRuleObj from '../shared/buildRuleObj'; import ScheduleForm from '../shared/ScheduleForm'; -function ScheduleAdd({ i18n, createSchedule }) { +function ScheduleAdd({ i18n, resource, apiModel }) { const [formSubmitError, setFormSubmitError] = useState(null); const history = useHistory(); const location = useLocation(); @@ -18,11 +18,8 @@ function ScheduleAdd({ i18n, createSchedule }) { const handleSubmit = async values => { try { const rule = new RRule(buildRuleObj(values, i18n)); - const { - data: { id: scheduleId }, - } = await createSchedule({ - name: values.name, - description: values.description, + + const { id: scheduleId } = await apiModel.createSchedule(resource.id, { rrule: rule.toString().replace(/\n/g, ' '), }); @@ -46,7 +43,7 @@ function ScheduleAdd({ i18n, createSchedule }) { } ScheduleAdd.propTypes = { - createSchedule: func.isRequired, + apiModel: shape({ createSchedule: func.isRequired }).isRequired, }; ScheduleAdd.defaultProps = {}; diff --git a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx index 946ac94f55..19ea0495c5 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx @@ -189,6 +189,14 @@ function ScheduleDetail({ schedule, i18n }) { showVerbosityDetail || showVariablesDetail; + const VERBOSITY = { + 0: i18n._(t`0 (Normal)`), + 1: i18n._(t`1 (Verbose)`), + 2: i18n._(t`2 (More Verbose)`), + 3: i18n._(t`3 (Debug)`), + 4: i18n._(t`4 (Connection Debug)`), + }; + if (isLoading) { return ; } @@ -256,6 +264,12 @@ function ScheduleDetail({ schedule, i18n }) { } /> )} + {ask_verbosity_on_launch && ( + + )} {ask_scm_branch_on_launch && ( - + - + - InventorySourcesAPI.createSchedule(source?.id, data); - const loadScheduleOptions = useCallback(() => { return InventorySourcesAPI.readScheduleOptions(source?.id); }, [source]); @@ -160,11 +157,11 @@ function InventorySource({ i18n, inventory, setBreadcrumb, me }) { path="/inventories/inventory/:id/sources/:sourceId/schedules" > + apiModel={InventorySourcesAPI} + setBreadcrumb={schedule => setBreadcrumb(inventory, source, schedule) } - unifiedJobTemplate={source} + resource={source} loadSchedules={loadSchedules} loadScheduleOptions={loadScheduleOptions} /> diff --git a/awx/ui_next/src/screens/Project/Project.jsx b/awx/ui_next/src/screens/Project/Project.jsx index 72341a5de9..5c3a5a7564 100644 --- a/awx/ui_next/src/screens/Project/Project.jsx +++ b/awx/ui_next/src/screens/Project/Project.jsx @@ -78,10 +78,6 @@ function Project({ i18n, setBreadcrumb }) { } }, [project, setBreadcrumb]); - function createSchedule(data) { - return ProjectsAPI.createSchedule(project.id, data); - } - const loadScheduleOptions = useCallback(() => { return ProjectsAPI.readScheduleOptions(project.id); }, [project]); @@ -188,8 +184,8 @@ function Project({ i18n, setBreadcrumb }) { diff --git a/awx/ui_next/src/screens/Template/Template.jsx b/awx/ui_next/src/screens/Template/Template.jsx index 50b238b3b7..5b8233b1af 100644 --- a/awx/ui_next/src/screens/Template/Template.jsx +++ b/awx/ui_next/src/screens/Template/Template.jsx @@ -86,10 +86,6 @@ function Template({ i18n, setBreadcrumb }) { } }, [template, setBreadcrumb]); - const createSchedule = data => { - return JobTemplatesAPI.createSchedule(template.id, data); - }; - const loadScheduleOptions = useCallback(() => { return JobTemplatesAPI.readScheduleOptions(templateId); }, [templateId]); @@ -203,9 +199,9 @@ function Template({ i18n, setBreadcrumb }) { path="/templates/:templateType/:id/schedules" > diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx index ee22010983..8d50bd937c 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx @@ -73,10 +73,6 @@ function WorkflowJobTemplate({ i18n, setBreadcrumb }) { loadTemplateAndRoles(); }, [loadTemplateAndRoles, location.pathname]); - const createSchedule = data => { - return WorkflowJobTemplatesAPI.createSchedule(templateId, data); - }; - const loadScheduleOptions = useCallback(() => { return WorkflowJobTemplatesAPI.readScheduleOptions(templateId); }, [templateId]); @@ -206,9 +202,9 @@ function WorkflowJobTemplate({ i18n, setBreadcrumb }) { path="/templates/:templateType/:id/schedules" > From c608d761a2c3dacb52256dff9a554c11385f900b Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 2 Feb 2021 14:35:31 -0500 Subject: [PATCH 017/397] Adds Prompting for schedule --- .../src/components/JobList/JobListItem.jsx | 8 + .../LaunchPrompt/steps/usePreviewStep.jsx | 5 +- .../src/components/Schedule/Schedule.jsx | 13 +- .../Schedule/ScheduleAdd/ScheduleAdd.jsx | 65 ++- .../Schedule/ScheduleAdd/ScheduleAdd.test.jsx | 219 ++++++++-- .../Schedule/ScheduleEdit/ScheduleEdit.jsx | 66 ++- .../ScheduleEdit/ScheduleEdit.test.jsx | 339 ++++++++++++++- .../Schedule/shared/ScheduleForm.jsx | 236 ++++++++-- .../Schedule/shared/ScheduleForm.test.jsx | 407 +++++++++++++++++- .../shared/SchedulePromptableFields.jsx | 127 ++++++ .../Schedule/shared/useSchedulePromptSteps.js | 102 +++++ .../src/screens/Job/JobDetail/JobDetail.jsx | 5 + .../screens/Job/JobDetail/JobDetail.test.jsx | 2 + 13 files changed, 1492 insertions(+), 102 deletions(-) create mode 100644 awx/ui_next/src/components/Schedule/shared/SchedulePromptableFields.jsx create mode 100644 awx/ui_next/src/components/Schedule/shared/useSchedulePromptSteps.js diff --git a/awx/ui_next/src/components/JobList/JobListItem.jsx b/awx/ui_next/src/components/JobList/JobListItem.jsx index 27047015de..bd10bf6467 100644 --- a/awx/ui_next/src/components/JobList/JobListItem.jsx +++ b/awx/ui_next/src/components/JobList/JobListItem.jsx @@ -160,6 +160,14 @@ function JobListItem({ } /> )} + + {job.job_explanation && ( + + )} diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx index 8a4cc73dde..a53c6d6a6c 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx @@ -11,7 +11,8 @@ export default function usePreviewStep( resource, surveyConfig, hasErrors, - showStep + showStep, + nextButtonText ) { return { step: showStep @@ -31,7 +32,7 @@ export default function usePreviewStep( /> ), enableNext: !hasErrors, - nextButtonText: i18n._(t`Launch`), + nextButtonText: nextButtonText || i18n._(t`Launch`), } : null, initialValues: {}, diff --git a/awx/ui_next/src/components/Schedule/Schedule.jsx b/awx/ui_next/src/components/Schedule/Schedule.jsx index 201841048c..7974ba072b 100644 --- a/awx/ui_next/src/components/Schedule/Schedule.jsx +++ b/awx/ui_next/src/components/Schedule/Schedule.jsx @@ -26,12 +26,7 @@ function Schedule({ i18n, setBreadcrumb, resource }) { const pathRoot = pathname.substr(0, pathname.indexOf('schedules')); - const { - isLoading: contentLoading, - error: contentError, - request: loadData, - result: schedule, - } = useRequest( + const { isLoading, error, request: loadData, result: schedule } = useRequest( useCallback(async () => { const { data } = await SchedulesAPI.readDetail(scheduleId); @@ -68,7 +63,7 @@ function Schedule({ i18n, setBreadcrumb, resource }) { }, ]; - if (contentLoading) { + if (isLoading) { return ; } @@ -85,8 +80,8 @@ function Schedule({ i18n, setBreadcrumb, resource }) { ); } - if (contentError) { - return ; + if (error) { + return ; } let showCardHeader = true; diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx index e6a9b81699..1fd59a91f7 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx @@ -1,12 +1,19 @@ import React, { useState } from 'react'; -import { func } from 'prop-types'; +import { func, shape } from 'prop-types'; import { withI18n } from '@lingui/react'; import { useHistory, useLocation } from 'react-router-dom'; import { RRule } from 'rrule'; import { Card } from '@patternfly/react-core'; +import yaml from 'js-yaml'; import { CardBody } from '../../Card'; +import { parseVariableField } from '../../../util/yaml'; + import buildRuleObj from '../shared/buildRuleObj'; import ScheduleForm from '../shared/ScheduleForm'; +import { SchedulesAPI } from '../../../api'; +import mergeExtraVars from '../../../util/prompt/mergeExtraVars'; +import getSurveyValues from '../../../util/prompt/getSurveyValues'; +import { getAddedAndRemoved } from '../../../util/lists'; function ScheduleAdd({ i18n, resource, apiModel }) { const [formSubmitError, setFormSubmitError] = useState(null); @@ -15,14 +22,63 @@ function ScheduleAdd({ i18n, resource, apiModel }) { const { pathname } = location; const pathRoot = pathname.substr(0, pathname.indexOf('schedules')); - const handleSubmit = async values => { + const handleSubmit = async (values, launchConfig, surveyConfig) => { + const { + inventory, + extra_vars, + originalCredentials, + end, + frequency, + interval, + startDateTime, + timezone, + occurrences, + runOn, + runOnTheDay, + runOnTheMonth, + runOnDayMonth, + runOnDayNumber, + endDateTime, + runOnTheOccurrence, + credentials, + daysOfWeek, + ...submitValues + } = values; + const { added } = getAddedAndRemoved( + resource?.summary_fields.credentials, + credentials + ); + let extraVars; + const surveyValues = getSurveyValues(values); + const initialExtraVars = + launchConfig?.ask_variables_on_launch && (values.extra_vars || '---'); + if (surveyConfig?.spec) { + extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, surveyValues)); + } else { + extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, {})); + } + submitValues.extra_data = extraVars && parseVariableField(extraVars); + delete values.extra_vars; + if (inventory) { + submitValues.inventory = inventory.id; + } + try { const rule = new RRule(buildRuleObj(values, i18n)); - const { id: scheduleId } = await apiModel.createSchedule(resource.id, { + const { + data: { id: scheduleId }, + } = await apiModel.createSchedule(resource.id, { + ...submitValues, rrule: rule.toString().replace(/\n/g, ' '), }); - + if (credentials?.length > 0) { + await Promise.all( + added.map(({ id: credentialId }) => + SchedulesAPI.associateCredential(scheduleId, credentialId) + ) + ); + } history.push(`${pathRoot}schedules/${scheduleId}`); } catch (err) { setFormSubmitError(err); @@ -36,6 +92,7 @@ function ScheduleAdd({ i18n, resource, apiModel }) { handleCancel={() => history.push(`${pathRoot}schedules`)} handleSubmit={handleSubmit} submitError={formSubmitError} + resource={resource} /> diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx index 176462f31e..41f6b02d1d 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx @@ -5,10 +5,12 @@ import { mountWithContexts, waitForElement, } from '../../../../testUtils/enzymeHelpers'; -import { SchedulesAPI } from '../../../api'; +import { SchedulesAPI, JobTemplatesAPI, InventoriesAPI } from '../../../api'; import ScheduleAdd from './ScheduleAdd'; jest.mock('../../../api/models/Schedules'); +jest.mock('../../../api/models/JobTemplates'); +jest.mock('../../../api/models/Inventories'); SchedulesAPI.readZoneInfo.mockResolvedValue({ data: [ @@ -17,22 +19,62 @@ SchedulesAPI.readZoneInfo.mockResolvedValue({ }, ], }); +JobTemplatesAPI.readLaunch.mockResolvedValue({ + data: { + can_start_without_user_input: false, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: true, + ask_credential_on_launch: false, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: false, + inventory_needed_to_start: true, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + defaults: { + extra_vars: '---', + diff_mode: false, + limit: '', + job_tags: '', + skip_tags: '', + job_type: 'run', + verbosity: 0, + inventory: { + name: null, + id: null, + }, + scm_branch: '', + }, + }, +}); +JobTemplatesAPI.createSchedule.mockResolvedValue({ data: { id: 3 } }); let wrapper; -const createSchedule = jest.fn().mockImplementation(() => { - return { - data: { - id: 1, - }, - }; -}); - describe('', () => { beforeAll(async () => { await act(async () => { wrapper = mountWithContexts( - + ); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); @@ -42,7 +84,7 @@ describe('', () => { }); test('Successfully creates a schedule with repeat frequency: None (run once)', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'none', @@ -52,16 +94,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run once schedule', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T100000 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', }); }); test('Successfully creates a schedule with 10 minute repeat frequency after 10 occurrences', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'after', frequency: 'minute', @@ -72,16 +115,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run every 10 minutes 10 times', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T103000 RRULE:INTERVAL=10;FREQ=MINUTELY;COUNT=10', }); }); test('Successfully creates a schedule with hourly repeat frequency ending on a specific date/time', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'onDate', endDateTime: '2020-03-26T10:45:00', @@ -92,16 +136,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run every hour until date', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=HOURLY;UNTIL=20200326T104500', }); }); test('Successfully creates a schedule with daily repeat frequency', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'day', @@ -111,16 +156,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run daily', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=DAILY', }); }); test('Successfully creates a schedule with weekly repeat frequency on mon/wed/fri', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ daysOfWeek: [RRule.MO, RRule.WE, RRule.FR], description: 'test description', end: 'never', @@ -132,15 +178,16 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run weekly on mon/wed/fri', + extra_data: {}, rrule: `DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=${RRule.MO},${RRule.WE},${RRule.FR}`, }); }); test('Successfully creates a schedule with monthly repeat frequency on the first day of the month', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'month', @@ -153,16 +200,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run on the first day of the month', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200401T104500 RRULE:INTERVAL=1;FREQ=MONTHLY;BYMONTHDAY=1', }); }); test('Successfully creates a schedule with monthly repeat frequency on the last tuesday of the month', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', endDateTime: '2020-03-26T11:00:00', @@ -177,16 +225,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run monthly on the last Tuesday', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200331T110000 RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=-1;BYDAY=TU', }); }); test('Successfully creates a schedule with yearly repeat frequency on the first day of March', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'year', @@ -200,16 +249,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Yearly on the first day of March', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200301T000000 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=1', }); }); test('Successfully creates a schedule with yearly repeat frequency on the second Friday in April', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'year', @@ -224,16 +274,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Yearly on the second Friday in April', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=2;BYDAY=FR;BYMONTH=4', }); }); test('Successfully creates a schedule with yearly repeat frequency on the first weekday in October', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'year', @@ -248,11 +299,119 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Yearly on the first weekday in October', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=1;BYDAY=MO,TU,WE,TH,FR;BYMONTH=10', }); }); + + test('should submit prompted data properly', async () => { + InventoriesAPI.read.mockResolvedValue({ + data: { + count: 2, + results: [ + { + name: 'Foo', + id: 1, + url: '', + }, + { + name: 'Bar', + id: 2, + url: '', + }, + ], + }, + }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { + related_search_fields: [], + actions: { + GET: { + filterable: true, + }, + }, + }, + }); + + await act(async () => + wrapper.find('Button[aria-label="Prompt"]').prop('onClick')() + ); + wrapper.update(); + expect( + wrapper + .find('WizardNavItem') + .at(0) + .prop('isCurrent') + ).toBe(true); + await act(async () => { + wrapper + .find('input[aria-labelledby="check-action-item-1"]') + .simulate('change', { + target: { + checked: true, + }, + }); + }); + wrapper.update(); + expect( + wrapper + .find('input[aria-labelledby="check-action-item-1"]') + .prop('checked') + ).toBe(true); + await act(async () => + wrapper.find('WizardFooterInternal').prop('onNext')() + ); + wrapper.update(); + expect( + wrapper + .find('WizardNavItem') + .at(1) + .prop('isCurrent') + ).toBe(true); + await act(async () => + wrapper.find('WizardFooterInternal').prop('onNext')() + ); + wrapper.update(); + expect(wrapper.find('Wizard').length).toBe(0); + // console.log(wrapper.debug()); + await act(async () => { + wrapper.find('Formik').invoke('onSubmit')({ + name: 'Schedule', + end: 'never', + endDateTime: '2021-01-29T14:15:00', + frequency: 'none', + occurrences: 1, + runOn: 'day', + runOnDayMonth: 1, + runOnDayNumber: 1, + runOnTheDay: 'sunday', + runOnTheMonth: 1, + runOnTheOccurrence: 1, + skip_tags: '', + inventory: { name: 'inventory', id: 45 }, + credentials: [ + { name: 'cred 1', id: 10 }, + { name: 'cred 2', id: 20 }, + ], + startDateTime: '2021-01-28T14:15:00', + timezone: 'America/New_York', + }); + }); + wrapper.update(); + + expect(JobTemplatesAPI.createSchedule).toBeCalledWith(700, { + extra_data: {}, + inventory: 45, + name: 'Schedule', + rrule: + 'DTSTART;TZID=America/New_York:20210128T141500 RRULE:COUNT=1;FREQ=MINUTELY', + skip_tags: '', + }); + expect(SchedulesAPI.associateCredential).toBeCalledWith(3, 10); + expect(SchedulesAPI.associateCredential).toBeCalledWith(3, 20); + }); }); diff --git a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx index 34aaf30068..e5faf4dbeb 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx @@ -4,10 +4,16 @@ import { useHistory, useLocation } from 'react-router-dom'; import { RRule } from 'rrule'; import { shape } from 'prop-types'; import { Card } from '@patternfly/react-core'; +import yaml from 'js-yaml'; import { CardBody } from '../../Card'; import { SchedulesAPI } from '../../../api'; import buildRuleObj from '../shared/buildRuleObj'; import ScheduleForm from '../shared/ScheduleForm'; +import { getAddedAndRemoved } from '../../../util/lists'; + +import { parseVariableField } from '../../../util/yaml'; +import mergeExtraVars from '../../../util/prompt/mergeExtraVars'; +import getSurveyValues from '../../../util/prompt/getSurveyValues'; function ScheduleEdit({ i18n, schedule, resource }) { const [formSubmitError, setFormSubmitError] = useState(null); @@ -16,16 +22,69 @@ function ScheduleEdit({ i18n, schedule, resource }) { const { pathname } = location; const pathRoot = pathname.substr(0, pathname.indexOf('schedules')); - const handleSubmit = async values => { + const handleSubmit = async ( + values, + launchConfig, + surveyConfig, + scheduleCredentials = [] + ) => { + const { + inventory, + credentials = [], + end, + frequency, + interval, + startDateTime, + timezone, + occurrences, + runOn, + runOnTheDay, + runOnTheMonth, + runOnDayMonth, + runOnDayNumber, + endDateTime, + runOnTheOccurrence, + daysOfWeek, + ...submitValues + } = values; + const { added, removed } = getAddedAndRemoved( + [...resource?.summary_fields.credentials, ...scheduleCredentials], + credentials + ); + + let extraVars; + const surveyValues = getSurveyValues(values); + const initialExtraVars = + launchConfig?.ask_variables_on_launch && (values.extra_vars || '---'); + if (surveyConfig?.spec) { + extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, surveyValues)); + } else { + extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, {})); + } + submitValues.extra_data = extraVars && parseVariableField(extraVars); + delete values.extra_vars; + if (inventory) { + submitValues.inventory = inventory.id; + } + try { const rule = new RRule(buildRuleObj(values, i18n)); const { data: { id: scheduleId }, } = await SchedulesAPI.update(schedule.id, { - name: values.name, - description: values.description, + ...submitValues, rrule: rule.toString().replace(/\n/g, ' '), }); + if (values.credentials?.length > 0) { + await Promise.all([ + ...removed.map(({ id }) => + SchedulesAPI.disassociateCredential(scheduleId, id) + ), + ...added.map(({ id }) => + SchedulesAPI.associateCredential(scheduleId, id) + ), + ]); + } history.push(`${pathRoot}schedules/${scheduleId}/details`); } catch (err) { @@ -43,6 +102,7 @@ function ScheduleEdit({ i18n, schedule, resource }) { } handleSubmit={handleSubmit} submitError={formSubmitError} + resource={resource} /> diff --git a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx index ed8b2c44e0..404f7334cb 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx @@ -5,10 +5,20 @@ import { mountWithContexts, waitForElement, } from '../../../../testUtils/enzymeHelpers'; -import { SchedulesAPI } from '../../../api'; +import { + SchedulesAPI, + JobTemplatesAPI, + InventoriesAPI, + CredentialsAPI, + CredentialTypesAPI, +} from '../../../api'; import ScheduleEdit from './ScheduleEdit'; jest.mock('../../../api/models/Schedules'); +jest.mock('../../../api/models/JobTemplates'); +jest.mock('../../../api/models/Inventories'); +jest.mock('../../../api/models/Credentials'); +jest.mock('../../../api/models/CredentialTypes'); SchedulesAPI.readZoneInfo.mockResolvedValue({ data: [ @@ -18,6 +28,75 @@ SchedulesAPI.readZoneInfo.mockResolvedValue({ ], }); +JobTemplatesAPI.readLaunch.mockResolvedValue({ + data: { + can_start_without_user_input: false, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: true, + ask_credential_on_launch: true, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: true, + inventory_needed_to_start: true, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + defaults: { + extra_vars: '---', + diff_mode: false, + limit: '', + job_tags: '', + skip_tags: '', + job_type: 'run', + verbosity: 0, + inventory: { + name: null, + id: null, + }, + scm_branch: '', + }, + }, +}); + +SchedulesAPI.readCredentials.mockResolvedValue({ + data: { + results: [ + { name: 'schedule credential 1', id: 1, kind: 'vault' }, + { name: 'schedule credential 2', id: 2, kind: 'aws' }, + ], + count: 2, + }, +}); + +CredentialTypesAPI.loadAllTypes.mockResolvedValue([ + { id: 1, name: 'ssh', kind: 'ssh' }, +]); + +CredentialsAPI.read.mockResolvedValue({ + data: { + count: 3, + results: [ + { id: 1, name: 'Credential 1', kind: 'ssh', url: '' }, + { id: 2, name: 'Credential 2', kind: 'ssh', url: '' }, + { id: 3, name: 'Credential 3', kind: 'ssh', url: '' }, + ], + }, +}); + +CredentialsAPI.readOptions.mockResolvedValue({ + data: { related_search_fields: [], actions: { GET: { filterabled: true } } }, +}); + SchedulesAPI.update.mockResolvedValue({ data: { id: 27, @@ -37,13 +116,14 @@ const mockSchedule = { edit: true, delete: true, }, + inventory: { id: 702, name: 'Inventory' }, }, created: '2020-04-02T18:43:12.664142Z', modified: '2020-04-02T18:43:12.664185Z', name: 'mock schedule', description: '', extra_data: {}, - inventory: null, + inventory: 1, scm_branch: null, job_type: null, job_tags: null, @@ -61,18 +141,33 @@ const mockSchedule = { }; describe('', () => { - beforeAll(async () => { + beforeEach(async () => { await act(async () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts( + + ); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); }); afterEach(() => { + wrapper.unmount(); jest.clearAllMocks(); }); test('Successfully creates a schedule with repeat frequency: None (run once)', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'none', @@ -85,13 +180,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run once schedule', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T100000 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', }); }); test('Successfully creates a schedule with 10 minute repeat frequency after 10 occurrences', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'after', frequency: 'minute', @@ -105,13 +201,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run every 10 minutes 10 times', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T103000 RRULE:INTERVAL=10;FREQ=MINUTELY;COUNT=10', }); }); test('Successfully creates a schedule with hourly repeat frequency ending on a specific date/time', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'onDate', endDateTime: '2020-03-26T10:45:00', @@ -125,13 +222,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run every hour until date', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=HOURLY;UNTIL=20200326T104500', }); }); test('Successfully creates a schedule with daily repeat frequency', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'day', @@ -144,13 +242,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run daily', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=DAILY', }); }); test('Successfully creates a schedule with weekly repeat frequency on mon/wed/fri', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ daysOfWeek: [RRule.MO, RRule.WE, RRule.FR], description: 'test description', end: 'never', @@ -165,12 +264,13 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run weekly on mon/wed/fri', + extra_data: {}, rrule: `DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=${RRule.MO},${RRule.WE},${RRule.FR}`, }); }); test('Successfully creates a schedule with monthly repeat frequency on the first day of the month', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'month', @@ -186,13 +286,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run on the first day of the month', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200401T104500 RRULE:INTERVAL=1;FREQ=MONTHLY;BYMONTHDAY=1', }); }); test('Successfully creates a schedule with monthly repeat frequency on the last tuesday of the month', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', endDateTime: '2020-03-26T11:00:00', @@ -210,13 +311,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run monthly on the last Tuesday', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200331T110000 RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=-1;BYDAY=TU', }); }); test('Successfully creates a schedule with yearly repeat frequency on the first day of March', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'year', @@ -233,13 +335,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Yearly on the first day of March', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200301T000000 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=1', }); }); test('Successfully creates a schedule with yearly repeat frequency on the second Friday in April', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'year', @@ -257,13 +360,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Yearly on the second Friday in April', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=2;BYDAY=FR;BYMONTH=4', }); }); test('Successfully creates a schedule with yearly repeat frequency on the first weekday in October', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'year', @@ -281,8 +385,215 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Yearly on the first weekday in October', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=1;BYDAY=MO,TU,WE,TH,FR;BYMONTH=10', }); }); + + test('should open with correct values and navigate through the Promptable fields properly', async () => { + InventoriesAPI.read.mockResolvedValue({ + data: { + count: 2, + results: [ + { + name: 'Foo', + id: 1, + url: '', + }, + { + name: 'Bar', + id: 2, + url: '', + }, + ], + }, + }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { + related_search_fields: [], + actions: { + GET: { + filterable: true, + }, + }, + }, + }); + + await act(async () => + wrapper.find('Button[aria-label="Prompt"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('WizardNavItem').length).toBe(3); + expect( + wrapper + .find('WizardNavItem') + .at(0) + .prop('isCurrent') + ).toBe(true); + + await act(async () => { + wrapper + .find('input[aria-labelledby="check-action-item-1"]') + .simulate('change', { + target: { + checked: true, + }, + }); + }); + wrapper.update(); + + expect( + wrapper + .find('input[aria-labelledby="check-action-item-1"]') + .prop('checked') + ).toBe(true); + await act(async () => + wrapper.find('WizardFooterInternal').prop('onNext')() + ); + wrapper.update(); + expect( + wrapper + .find('WizardNavItem') + .at(1) + .prop('isCurrent') + ).toBe(true); + + expect(wrapper.find('CredentialChip').length).toBe(3); + + wrapper.update(); + + await act(async () => { + wrapper + .find('input[aria-labelledby="check-action-item-3"]') + .simulate('change', { + target: { + checked: true, + }, + }); + }); + wrapper.update(); + expect( + wrapper + .find('input[aria-labelledby="check-action-item-3"]') + .prop('checked') + ).toBe(true); + await act(async () => + wrapper.find('WizardFooterInternal').prop('onNext')() + ); + wrapper.update(); + await act(async () => + wrapper.find('WizardFooterInternal').prop('onNext')() + ); + wrapper.update(); + expect(wrapper.find('Wizard').length).toBe(0); + await act(async () => { + wrapper.find('Formik').invoke('onSubmit')({ + name: mockSchedule.name, + end: 'never', + endDateTime: '2021-01-29T14:15:00', + frequency: 'none', + occurrences: 1, + runOn: 'day', + runOnDayMonth: 1, + runOnDayNumber: 1, + runOnTheDay: 'sunday', + runOnTheMonth: 1, + runOnTheOccurrence: 1, + skip_tags: '', + startDateTime: '2021-01-28T14:15:00', + timezone: 'America/New_York', + credentials: [ + { id: 3, name: 'Credential 3', kind: 'ssh', url: '' }, + { name: 'schedule credential 1', id: 1, kind: 'vault' }, + { name: 'schedule credential 2', id: 2, kind: 'aws' }, + ], + }); + }); + wrapper.update(); + + expect(SchedulesAPI.update).toBeCalledWith(27, { + extra_data: {}, + name: 'mock schedule', + rrule: + 'DTSTART;TZID=America/New_York:20210128T141500 RRULE:COUNT=1;FREQ=MINUTELY', + skip_tags: '', + }); + expect(SchedulesAPI.disassociateCredential).toBeCalledWith(27, 75); + + expect(SchedulesAPI.associateCredential).toBeCalledWith(27, 3); + }); + + test('should submit updated static form values, but original prompt form values', async () => { + InventoriesAPI.read.mockResolvedValue({ + data: { + count: 2, + results: [ + { + name: 'Foo', + id: 1, + url: '', + }, + { + name: 'Bar', + id: 2, + url: '', + }, + ], + }, + }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { + related_search_fields: [], + actions: { + GET: { + filterable: true, + }, + }, + }, + }); + await act(async () => + wrapper.find('input#schedule-name').simulate('change', { + target: { value: 'foo', name: 'name' }, + }) + ); + wrapper.update(); + await act(async () => + wrapper.find('Button[aria-label="Prompt"]').prop('onClick')() + ); + wrapper.update(); + await act(async () => { + wrapper + .find('input[aria-labelledby="check-action-item-2"]') + .simulate('change', { + target: { + checked: true, + }, + }); + }); + wrapper.update(); + + expect( + wrapper + .find('input[aria-labelledby="check-action-item-2"]') + .prop('checked') + ).toBe(true); + await act(async () => + wrapper.find('WizardFooterInternal').prop('onClose')() + ); + wrapper.update(); + expect(wrapper.find('Wizard').length).toBe(0); + + await act(async () => + wrapper.find('Button[aria-label="Save"]').prop('onClick')() + ); + expect(SchedulesAPI.update).toBeCalledWith(27, { + description: '', + extra_data: {}, + inventory: 702, + name: 'foo', + rrule: + 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', + }); + }); }); diff --git a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx index 756e400258..35504df3b6 100644 --- a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx @@ -1,22 +1,36 @@ -import React, { useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback, useState } from 'react'; import { shape, func } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Formik, useField } from 'formik'; import { RRule } from 'rrule'; -import { Form, FormGroup, Title } from '@patternfly/react-core'; +import { + Button, + Form, + FormGroup, + Title, + ActionGroup, +} from '@patternfly/react-core'; import { Config } from '../../../contexts/Config'; -import { SchedulesAPI } from '../../../api'; +import { + SchedulesAPI, + JobTemplatesAPI, + WorkflowJobTemplatesAPI, +} from '../../../api'; import AnsibleSelect from '../../AnsibleSelect'; import ContentError from '../../ContentError'; import ContentLoading from '../../ContentLoading'; -import FormActionGroup from '../../FormActionGroup/FormActionGroup'; import FormField, { FormSubmitError } from '../../FormField'; -import { FormColumnLayout, SubFormLayout } from '../../FormLayout'; +import { + FormColumnLayout, + SubFormLayout, + FormFullWidthLayout, +} from '../../FormLayout'; import { dateToInputDateTime, formatDateStringUTC } from '../../../util/dates'; import useRequest from '../../../util/useRequest'; import { required } from '../../../util/validators'; import FrequencyDetailSubform from './FrequencyDetailSubform'; +import SchedulePromptableFields from './SchedulePromptableFields'; const generateRunOnTheDay = (days = []) => { if ( @@ -179,8 +193,12 @@ function ScheduleForm({ i18n, schedule, submitError, + resource, ...rest }) { + const [isWizardOpen, setIsWizardOpen] = useState(false); + const [isSaveDisabled, setIsSaveDisabled] = useState(false); + let rruleError; const now = new Date(); const closestQuarterHour = new Date( @@ -189,6 +207,123 @@ function ScheduleForm({ const tomorrow = new Date(closestQuarterHour); tomorrow.setDate(tomorrow.getDate() + 1); + const isTemplate = + resource.type === 'workflow_job_template' || + resource.type === 'job_template'; + const isWorkflowJobTemplate = + isTemplate && resource.type === 'workflow_job_template'; + + const { + request: loadScheduleData, + error: contentError, + contentLoading, + result: { zoneOptions, surveyConfig, launchConfig, credentials }, + } = useRequest( + useCallback(async () => { + const readLaunch = + isTemplate && + (isWorkflowJobTemplate + ? WorkflowJobTemplatesAPI.readLaunch(resource.id) + : JobTemplatesAPI.readLaunch(resource.id)); + const [{ data }, { data: launchConfiguration }] = await Promise.all([ + SchedulesAPI.readZoneInfo(), + readLaunch, + ]); + + const readSurvey = isWorkflowJobTemplate + ? WorkflowJobTemplatesAPI.readSurvey(resource.id) + : JobTemplatesAPI.readSurvey(resource.id); + + let surveyConfiguration = null; + + if (isTemplate && launchConfiguration.survey_enabled) { + const { data: survey } = await readSurvey; + + surveyConfiguration = survey; + } + let creds; + if (schedule.id) { + const { + data: { results }, + } = await SchedulesAPI.readCredentials(schedule.id); + creds = results; + } + + const missingRequiredInventory = Boolean( + !resource.inventory && !schedule?.summary_fields?.inventory.id + ); + let missingRequiredSurvey = false; + + if ( + schedule.id && + isTemplate && + !launchConfiguration?.can_start_without_user_input + ) { + missingRequiredSurvey = surveyConfiguration?.spec?.every(question => { + let hasValue; + if (Object.keys(schedule)?.length === 0) { + hasValue = true; + } + Object.entries(schedule?.extra_data).forEach(([key, value]) => { + if ( + question.required && + question.variable === key && + value.length > 0 + ) { + hasValue = false; + } + }); + return hasValue; + }); + } + if (missingRequiredInventory || missingRequiredSurvey) { + setIsSaveDisabled(true); + } + + const zones = data.map(zone => { + return { + value: zone.name, + key: zone.name, + label: zone.name, + }; + }); + + return { + zoneOptions: zones, + surveyConfig: surveyConfiguration || {}, + launchConfig: launchConfiguration, + credentials: creds || [], + }; + }, [isTemplate, isWorkflowJobTemplate, resource, schedule]), + { + zonesOptions: [], + surveyConfig: {}, + launchConfig: {}, + credentials: [], + } + ); + + useEffect(() => { + loadScheduleData(); + }, [loadScheduleData]); + + let showPromptButton = false; + + if ( + launchConfig && + (launchConfig.ask_inventory_on_launch || + launchConfig.ask_variables_on_launch || + launchConfig.ask_job_type_on_launch || + launchConfig.ask_limit_on_launch || + launchConfig.ask_credential_on_launch || + launchConfig.ask_scm_branch_on_launch || + launchConfig.survey_enabled || + launchConfig.inventory_needed_to_start || + launchConfig.variables_needed_to_start?.length > 0) + ) { + showPromptButton = true; + } + const initialValues = { daysOfWeek: [], description: schedule.description || '', @@ -207,6 +342,19 @@ function ScheduleForm({ startDateTime: dateToInputDateTime(closestQuarterHour), timezone: schedule.timezone || 'America/New_York', }; + const submitSchedule = ( + values, + launchConfiguration, + surveyConfiguration, + scheduleCredentials + ) => { + handleSubmit( + values, + launchConfiguration, + surveyConfiguration, + scheduleCredentials + ); + }; const overriddenValues = {}; @@ -297,28 +445,6 @@ function ScheduleForm({ } } - const { - request: loadZoneInfo, - error: contentError, - contentLoading, - result: zoneOptions, - } = useRequest( - useCallback(async () => { - const { data } = await SchedulesAPI.readZoneInfo(); - return data.map(zone => { - return { - value: zone.name, - key: zone.name, - label: zone.name, - }; - }); - }, []) - ); - - useEffect(() => { - loadZoneInfo(); - }, [loadZoneInfo]); - if (contentError || rruleError) { return ; } @@ -333,7 +459,9 @@ function ScheduleForm({ return ( { + submitSchedule(values, launchConfig, surveyConfig, credentials); + }} validate={values => { const errors = {}; const { @@ -375,11 +503,55 @@ function ScheduleForm({ zoneOptions={zoneOptions} {...rest} /> + {isWizardOpen && ( + { + setIsWizardOpen(false); + setIsSaveDisabled(hasErrors); + }} + onSave={() => { + setIsWizardOpen(false); + setIsSaveDisabled(false); + }} + /> + )} - + + + + {isTemplate && showPromptButton && ( + + )} + + + )} diff --git a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx index 771a129b9e..c8fa312dc1 100644 --- a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx @@ -1,11 +1,71 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import { SchedulesAPI } from '../../../api'; + +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import { SchedulesAPI, JobTemplatesAPI, InventoriesAPI } from '../../../api'; import ScheduleForm from './ScheduleForm'; jest.mock('../../../api/models/Schedules'); +jest.mock('../../../api/models/JobTemplates'); +jest.mock('../../../api/models/Inventories'); +const survey = { + name: '', + description: '', + spec: [ + { + question_name: 'new survey', + question_description: '', + required: true, + type: 'text', + variable: 'newsurveyquestion', + min: 0, + max: 1024, + default: '', + choices: '', + new_question: false, + }, + ], +}; +const credentials = { + data: { + results: [ + { id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' }, + { id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' }, + { id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' }, + { id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' }, + { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, + ], + }, +}; +const launchData = { + data: { + can_start_without_user_input: false, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: true, + ask_credential_on_launch: false, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: false, + inventory_needed_to_start: true, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + }, +}; const mockSchedule = { rrule: 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', @@ -23,7 +83,7 @@ const mockSchedule = { name: 'mock schedule', description: 'test description', extra_data: {}, - inventory: null, + inventory: 1, scm_branch: null, job_type: null, job_tags: null, @@ -82,7 +142,11 @@ describe('', () => { ); await act(async () => { wrapper = mountWithContexts( - + ); }); wrapper.update(); @@ -92,6 +156,10 @@ describe('', () => { describe('Cancel', () => { test('should make the appropriate callback', async () => { const handleCancel = jest.fn(); + JobTemplatesAPI.readLaunch.mockResolvedValue(launchData); + + JobTemplatesAPI.readSurvey.mockResolvedValue(survey); + SchedulesAPI.readCredentials.mockResolvedValue(credentials); SchedulesAPI.readZoneInfo.mockResolvedValue({ data: [ { @@ -101,7 +169,11 @@ describe('', () => { }); await act(async () => { wrapper = mountWithContexts( - + ); }); wrapper.update(); @@ -111,6 +183,173 @@ describe('', () => { expect(handleCancel).toHaveBeenCalledTimes(1); }); }); + describe('Prompted Schedule', () => { + let promptWrapper; + beforeEach(async () => { + JobTemplatesAPI.readLaunch.mockResolvedValue({ + data: { + can_start_without_user_input: false, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: true, + ask_credential_on_launch: false, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: false, + inventory_needed_to_start: true, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + }, + }); + SchedulesAPI.readZoneInfo.mockResolvedValue({ + data: [ + { + name: 'America/New_York', + }, + ], + }); + await act(async () => { + promptWrapper = mountWithContexts( + + ); + }); + waitForElement( + promptWrapper, + 'Button[aria-label="Prompt"]', + el => el.length > 0 + ); + }); + afterEach(() => { + promptWrapper.unmount(); + jest.clearAllMocks(); + }); + + test('should open prompt modal with proper steps and default values', async () => { + await act(async () => + promptWrapper.find('Button[aria-label="Prompt"]').prop('onClick')() + ); + promptWrapper.update(); + waitForElement(promptWrapper, 'Wizard', el => el.length > 0); + expect(promptWrapper.find('Wizard').length).toBe(1); + expect(promptWrapper.find('StepName#inventory-step').length).toBe(2); + expect(promptWrapper.find('StepName#preview-step').length).toBe(1); + expect(promptWrapper.find('WizardNavItem').length).toBe(2); + }); + + test('should update prompt modal data', async () => { + InventoriesAPI.read.mockResolvedValue({ + data: { + count: 2, + results: [ + { + name: 'Foo', + id: 1, + url: '', + }, + { + name: 'Bar', + id: 2, + url: '', + }, + ], + }, + }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { + related_search_fields: [], + actions: { + GET: { + filterable: true, + }, + }, + }, + }); + + await act(async () => + promptWrapper.find('Button[aria-label="Prompt"]').prop('onClick')() + ); + promptWrapper.update(); + expect( + promptWrapper + .find('WizardNavItem') + .at(0) + .prop('isCurrent') + ).toBe(true); + await act(async () => { + promptWrapper + .find('input[aria-labelledby="check-action-item-1"]') + .simulate('change', { + target: { + checked: true, + }, + }); + }); + promptWrapper.update(); + expect( + promptWrapper + .find('input[aria-labelledby="check-action-item-1"]') + .prop('checked') + ).toBe(true); + await act(async () => + promptWrapper.find('WizardFooterInternal').prop('onNext')() + ); + promptWrapper.update(); + expect( + promptWrapper + .find('WizardNavItem') + .at(1) + .prop('isCurrent') + ).toBe(true); + await act(async () => + promptWrapper.find('WizardFooterInternal').prop('onNext')() + ); + promptWrapper.update(); + expect(promptWrapper.find('Wizard').length).toBe(0); + }); + test('should render prompt button with disabled save button', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + waitForElement( + wrapper, + 'Button[aria-label="Prompt"]', + el => el.length > 0 + ); + expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe( + true + ); + }); + }); describe('Add', () => { beforeAll(async () => { SchedulesAPI.readZoneInfo.mockResolvedValue({ @@ -120,9 +359,39 @@ describe('', () => { }, ], }); + JobTemplatesAPI.readLaunch.mockResolvedValue({ + data: { + can_start_without_user_input: true, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: false, + ask_credential_on_launch: false, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: false, + inventory_needed_to_start: false, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + }, + }); + await act(async () => { wrapper = mountWithContexts( - + ); }); }); @@ -312,6 +581,14 @@ describe('', () => { expect(wrapper.find('select#schedule-run-on-the-month').length).toBe(1); }); test('occurrences field properly shown when end after selection is made', async () => { + await act(async () => { + wrapper + .find('FormGroup[label="Run frequency"] FormSelect') + .invoke('onChange')('minute', { + target: { value: 'minute', key: 'minute', label: 'Minute' }, + }); + }); + wrapper.update(); await act(async () => { wrapper.find('Radio#end-after').invoke('onChange')('after', { target: { name: 'end' }, @@ -331,6 +608,14 @@ describe('', () => { wrapper.update(); }); test('error shown when end date/time comes before start date/time', async () => { + await act(async () => { + wrapper + .find('FormGroup[label="Run frequency"] FormSelect') + .invoke('onChange')('minute', { + target: { value: 'minute', key: 'minute', label: 'Minute' }, + }); + }); + wrapper.update(); expect(wrapper.find('input#end-never').prop('checked')).toBe(true); expect(wrapper.find('input#end-after').prop('checked')).toBe(false); expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false); @@ -361,13 +646,28 @@ describe('', () => { ); }); test('error shown when on day number is not between 1 and 31', async () => { - await act(async () => { + act(() => { + wrapper.find('select[id="schedule-frequency"]').invoke('onChange')( + { + currentTarget: { value: 'month', type: 'change' }, + target: { name: 'frequency', value: 'month' }, + }, + 'month' + ); + }); + wrapper.update(); + + act(() => { wrapper.find('input#schedule-run-on-day-number').simulate('change', { target: { value: 32, name: 'runOnDayNumber' }, }); }); wrapper.update(); + expect( + wrapper.find('input#schedule-run-on-day-number').prop('value') + ).toBe(32); + await act(async () => { wrapper.find('button[aria-label="Save"]').simulate('click'); }); @@ -379,7 +679,7 @@ describe('', () => { }); }); describe('Edit', () => { - beforeAll(async () => { + beforeEach(async () => { SchedulesAPI.readZoneInfo.mockResolvedValue({ data: [ { @@ -387,10 +687,94 @@ describe('', () => { }, ], }); + JobTemplatesAPI.readLaunch.mockResolvedValue({ + data: { + can_start_without_user_input: true, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: false, + ask_credential_on_launch: false, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: false, + inventory_needed_to_start: false, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + }, + }); + JobTemplatesAPI.readSurvey.mockResolvedValue({}); + SchedulesAPI.readCredentials.mockResolvedValue(credentials); }); afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + test('should make API calls to fetch credentials, launch configuration, and survey configuration', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + expect(JobTemplatesAPI.readLaunch).toBeCalledWith(23); + expect(JobTemplatesAPI.readSurvey).toBeCalledWith(23); + expect(SchedulesAPI.readCredentials).toBeCalledWith(27); + }); + + test('should not call API to get credentials ', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + + expect(SchedulesAPI.readCredentials).not.toBeCalled(); + }); + + test('should render prompt button with enabled save button for project', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + waitForElement( + wrapper, + 'Button[aria-label="Prompt"]', + el => el.length > 0 + ); + + expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe( + false + ); + }); + test('initially renders expected fields and values with existing schedule that runs once', async () => { await act(async () => { wrapper = mountWithContexts( @@ -398,6 +782,7 @@ describe('', () => { handleSubmit={jest.fn()} handleCancel={jest.fn()} schedule={mockSchedule} + resource={{ id: 23, type: 'job_template' }} /> ); }); @@ -426,6 +811,7 @@ describe('', () => { 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=10;FREQ=MINUTELY', dtend: null, })} + resource={{ id: 23, type: 'job_template' }} /> ); }); @@ -459,6 +845,7 @@ describe('', () => { dtend: '2020-04-03T03:45:00Z', until: '', })} + resource={{ id: 23, type: 'job_template' }} /> ); }); @@ -493,6 +880,7 @@ describe('', () => { dtend: null, until: '', })} + resource={{ id: 23, type: 'job_template' }} /> ); expect(wrapper.find('ScheduleForm').length).toBe(1); @@ -526,6 +914,7 @@ describe('', () => { dtend: '2020-10-30T18:45:00Z', until: '2021-01-01T00:00:00', })} + resource={{ id: 23, type: 'job_template' }} /> ); }); @@ -583,6 +972,7 @@ describe('', () => { dtend: null, until: '', })} + resource={{ id: 23, type: 'job_template' }} /> ); expect(wrapper.find('ScheduleForm').length).toBe(1); @@ -628,6 +1018,7 @@ describe('', () => { dtend: null, until: '', })} + resource={{ id: 23, type: 'job_template' }} /> ); expect(wrapper.find('ScheduleForm').length).toBe(1); diff --git a/awx/ui_next/src/components/Schedule/shared/SchedulePromptableFields.jsx b/awx/ui_next/src/components/Schedule/shared/SchedulePromptableFields.jsx new file mode 100644 index 0000000000..0b0e8d2f3c --- /dev/null +++ b/awx/ui_next/src/components/Schedule/shared/SchedulePromptableFields.jsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { Wizard } from '@patternfly/react-core'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useFormikContext } from 'formik'; +import AlertModal from '../../AlertModal'; +import { useDismissableError } from '../../../util/useRequest'; +import ContentError from '../../ContentError'; +import ContentLoading from '../../ContentLoading'; +import useSchedulePromptSteps from './useSchedulePromptSteps'; + +function SchedulePromptableFields({ + schedule, + surveyConfig, + launchConfig, + onCloseWizard, + onSave, + credentials, + resource, + i18n, +}) { + const { + validateForm, + setFieldTouched, + values, + initialValues, + resetForm, + } = useFormikContext(); + const { + steps, + visitStep, + visitAllSteps, + validateStep, + contentError, + isReady, + } = useSchedulePromptSteps( + surveyConfig, + launchConfig, + schedule, + resource, + i18n, + credentials + ); + + const { error, dismissError } = useDismissableError(contentError); + const cancelPromptableValues = async () => { + const hasErrors = await validateForm(); + resetForm({ + values: { + ...initialValues, + daysOfWeek: values.daysOfWeek, + description: values.description, + end: values.end, + endDateTime: values.endDateTime, + frequency: values.frequency, + interval: values.interval, + name: values.name, + occurences: values.occurances, + runOn: values.runOn, + runOnDayMonth: values.runOnDayMonth, + runOnDayNumber: values.runOnDayNumber, + runOnTheDay: values.runOnTheDay, + runOnTheMonth: values.runOnTheMonth, + runOnTheOccurence: values.runOnTheOccurance, + startDateTime: values.startDateTime, + timezone: values.timezone, + }, + }); + onCloseWizard(Object.keys(hasErrors).length > 0); + }; + + if (error) { + return ( + { + dismissError(); + onCloseWizard(); + }} + > + + + ); + } + return ( + { + if (nextStep.id === 'preview') { + visitAllSteps(setFieldTouched); + } else { + visitStep(prevStep.prevId, setFieldTouched); + } + await validateForm(); + }} + onGoToStep={async (nextStep, prevStep) => { + if (nextStep.id === 'preview') { + visitAllSteps(setFieldTouched); + } else { + visitStep(prevStep.prevId, setFieldTouched); + validateStep(nextStep.id); + } + await validateForm(); + }} + title={i18n._(t`Prompts`)} + steps={ + isReady + ? steps + : [ + { + name: i18n._(t`Content Loading`), + component: , + }, + ] + } + backButtonText={i18n._(t`Back`)} + cancelButtonText={i18n._(t`Cancel`)} + nextButtonText={i18n._(t`Next`)} + /> + ); +} + +export default withI18n()(SchedulePromptableFields); diff --git a/awx/ui_next/src/components/Schedule/shared/useSchedulePromptSteps.js b/awx/ui_next/src/components/Schedule/shared/useSchedulePromptSteps.js new file mode 100644 index 0000000000..94d3dd5fb4 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/shared/useSchedulePromptSteps.js @@ -0,0 +1,102 @@ +import { useState, useEffect } from 'react'; +import { useFormikContext } from 'formik'; +import { t } from '@lingui/macro'; +import useInventoryStep from '../../LaunchPrompt/steps/useInventoryStep'; +import useCredentialsStep from '../../LaunchPrompt/steps/useCredentialsStep'; +import useOtherPromptsStep from '../../LaunchPrompt/steps/useOtherPromptsStep'; +import useSurveyStep from '../../LaunchPrompt/steps/useSurveyStep'; +import usePreviewStep from '../../LaunchPrompt/steps/usePreviewStep'; + +export default function useSchedulePromptSteps( + surveyConfig, + launchConfig, + schedule, + resource, + i18n, + scheduleCredentials +) { + const { + summary_fields: { credentials: resourceCredentials }, + } = resource; + const sourceOfValues = + (Object.keys(schedule).length > 0 && schedule) || resource; + + sourceOfValues.summary_fields = { + credentials: [...resourceCredentials, ...scheduleCredentials], + ...sourceOfValues.summary_fields, + }; + const { resetForm, values } = useFormikContext(); + const [visited, setVisited] = useState({}); + + const steps = [ + useInventoryStep(launchConfig, sourceOfValues, i18n, visited), + useCredentialsStep(launchConfig, sourceOfValues, i18n), + useOtherPromptsStep(launchConfig, sourceOfValues, i18n), + useSurveyStep(launchConfig, surveyConfig, sourceOfValues, i18n, visited), + ]; + + const hasErrors = steps.some(step => step.hasError); + steps.push( + usePreviewStep( + launchConfig, + i18n, + resource, + surveyConfig, + hasErrors, + true, + i18n._(t`Save`) + ) + ); + + const pfSteps = steps.map(s => s.step).filter(s => s != null); + const isReady = !steps.some(s => !s.isReady); + + useEffect(() => { + let initialValues = {}; + if (launchConfig && surveyConfig && isReady) { + initialValues = steps.reduce((acc, cur) => { + return { + ...acc, + ...cur.initialValues, + }; + }, {}); + } + resetForm({ + values: { + ...initialValues, + ...values, + }, + }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [launchConfig, surveyConfig, isReady]); + + const stepWithError = steps.find(s => s.contentError); + const contentError = stepWithError ? stepWithError.contentError : null; + + return { + isReady, + validateStep: stepId => { + steps.find(s => s?.step?.id === stepId).validate(); + }, + steps: pfSteps, + visitStep: (prevStepId, setFieldTouched) => { + setVisited({ + ...visited, + [prevStepId]: true, + }); + steps.find(s => s?.step?.id === prevStepId).setTouched(setFieldTouched); + }, + visitAllSteps: setFieldTouched => { + setVisited({ + inventory: true, + credentials: true, + other: true, + survey: true, + preview: true, + }); + steps.forEach(s => s.setTouched(setFieldTouched)); + }, + contentError, + }; +} diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx index b50b25ea23..04063ed310 100644 --- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx +++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx @@ -326,6 +326,11 @@ function JobDetail({ job, i18n }) { user={created_by} /> + {job.extra_vars && ( ', () => { name: 'Test Source Workflow', }, }, + job_explanation: 'It failed, bummer!', }} /> ); @@ -69,6 +70,7 @@ describe('', () => { assertDetail('Job Slice', '0/1'); assertDetail('Credentials', 'SSH: Demo Credential'); assertDetail('Machine Credential', 'SSH: Machine cred'); + assertDetail('Explanation', 'It failed, bummer!'); const credentialChip = wrapper.find( `Detail[label="Credentials"] CredentialChip` From 561390d405b8028c913a6536c68eda8ec0337802 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Mon, 15 Feb 2021 13:44:15 -0500 Subject: [PATCH 018/397] Refactors to add warning icon and disable save if schedule has missing values --- .../src/components/Schedule/Schedule.jsx | 17 +- .../Schedule/ScheduleAdd/ScheduleAdd.jsx | 15 +- .../Schedule/ScheduleAdd/ScheduleAdd.test.jsx | 78 ++--- .../ScheduleDetail/ScheduleDetail.jsx | 58 +++- .../ScheduleDetail/ScheduleDetail.test.jsx | 36 +++ .../Schedule/ScheduleEdit/ScheduleEdit.jsx | 32 +- .../ScheduleEdit/ScheduleEdit.test.jsx | 95 +++--- .../Schedule/ScheduleList/ScheduleList.jsx | 46 +++ .../ScheduleList/ScheduleList.test.jsx | 61 +++- .../ScheduleList/ScheduleListItem.jsx | 38 ++- .../ScheduleList/ScheduleListItem.test.jsx | 50 +++- .../ScheduleToggle/ScheduleToggle.jsx | 6 +- .../src/components/Schedule/Schedules.jsx | 19 +- .../components/Schedule/data.schedules.json | 7 +- .../Schedule/shared/ScheduleForm.jsx | 119 ++++---- .../Schedule/shared/ScheduleForm.test.jsx | 277 +++++++++++------- .../Schedule/shared/useSchedulePromptSteps.js | 2 +- .../src/screens/Job/JobDetail/JobDetail.jsx | 5 - .../screens/Job/JobDetail/JobDetail.test.jsx | 2 - awx/ui_next/src/screens/Template/Template.jsx | 21 +- .../src/screens/Template/Template.test.jsx | 3 +- .../screens/Template/WorkflowJobTemplate.jsx | 24 +- .../Template/WorkflowJobTemplate.test.jsx | 3 +- 23 files changed, 704 insertions(+), 310 deletions(-) diff --git a/awx/ui_next/src/components/Schedule/Schedule.jsx b/awx/ui_next/src/components/Schedule/Schedule.jsx index 7974ba072b..e1e59c5d85 100644 --- a/awx/ui_next/src/components/Schedule/Schedule.jsx +++ b/awx/ui_next/src/components/Schedule/Schedule.jsx @@ -19,7 +19,13 @@ import ScheduleEdit from './ScheduleEdit'; import { SchedulesAPI } from '../../api'; import useRequest from '../../util/useRequest'; -function Schedule({ i18n, setBreadcrumb, resource }) { +function Schedule({ + i18n, + setBreadcrumb, + resource, + launchConfig, + surveyConfig, +}) { const { scheduleId } = useParams(); const { pathname } = useLocation(); @@ -100,13 +106,18 @@ function Schedule({ i18n, setBreadcrumb, resource }) { /> {schedule && [ - + , - + , ]} diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx index 1fd59a91f7..81f42f5b72 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx @@ -15,14 +15,18 @@ import mergeExtraVars from '../../../util/prompt/mergeExtraVars'; import getSurveyValues from '../../../util/prompt/getSurveyValues'; import { getAddedAndRemoved } from '../../../util/lists'; -function ScheduleAdd({ i18n, resource, apiModel }) { +function ScheduleAdd({ i18n, resource, apiModel, launchConfig, surveyConfig }) { const [formSubmitError, setFormSubmitError] = useState(null); const history = useHistory(); const location = useLocation(); const { pathname } = location; const pathRoot = pathname.substr(0, pathname.indexOf('schedules')); - const handleSubmit = async (values, launchConfig, surveyConfig) => { + const handleSubmit = async ( + values, + launchConfiguration, + surveyConfiguration + ) => { const { inventory, extra_vars, @@ -51,8 +55,9 @@ function ScheduleAdd({ i18n, resource, apiModel }) { let extraVars; const surveyValues = getSurveyValues(values); const initialExtraVars = - launchConfig?.ask_variables_on_launch && (values.extra_vars || '---'); - if (surveyConfig?.spec) { + launchConfiguration?.ask_variables_on_launch && + (values.extra_vars || '---'); + if (surveyConfiguration?.spec) { extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, surveyValues)); } else { extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, {})); @@ -92,6 +97,8 @@ function ScheduleAdd({ i18n, resource, apiModel }) { handleCancel={() => history.push(`${pathRoot}schedules`)} handleSubmit={handleSubmit} submitError={formSubmitError} + launchConfig={launchConfig} + surveyConfig={surveyConfig} resource={resource} /> diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx index 41f6b02d1d..970bb91476 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx @@ -19,45 +19,45 @@ SchedulesAPI.readZoneInfo.mockResolvedValue({ }, ], }); -JobTemplatesAPI.readLaunch.mockResolvedValue({ - data: { - can_start_without_user_input: false, - passwords_needed_to_start: [], - ask_scm_branch_on_launch: false, - ask_variables_on_launch: false, - ask_tags_on_launch: false, - ask_diff_mode_on_launch: false, - ask_skip_tags_on_launch: false, - ask_job_type_on_launch: false, - ask_limit_on_launch: false, - ask_verbosity_on_launch: false, - ask_inventory_on_launch: true, - ask_credential_on_launch: false, - survey_enabled: false, - variables_needed_to_start: [], - credential_needed_to_start: false, - inventory_needed_to_start: true, - job_template_data: { - name: 'Demo Job Template', - id: 7, - description: '', - }, - defaults: { - extra_vars: '---', - diff_mode: false, - limit: '', - job_tags: '', - skip_tags: '', - job_type: 'run', - verbosity: 0, - inventory: { - name: null, - id: null, - }, - scm_branch: '', - }, + +const launchConfig = { + can_start_without_user_input: false, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: true, + ask_credential_on_launch: false, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: false, + inventory_needed_to_start: true, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', }, -}); + defaults: { + extra_vars: '---', + diff_mode: false, + limit: '', + job_tags: '', + skip_tags: '', + job_type: 'run', + verbosity: 0, + inventory: { + name: null, + id: null, + }, + scm_branch: '', + }, +}; + JobTemplatesAPI.createSchedule.mockResolvedValue({ data: { id: 3 } }); let wrapper; @@ -74,6 +74,7 @@ describe('', () => { inventory: 2, summary_fields: { credentials: [] }, }} + launchConfig={launchConfig} /> ); }); @@ -377,7 +378,6 @@ describe('', () => { ); wrapper.update(); expect(wrapper.find('Wizard').length).toBe(0); - // console.log(wrapper.debug()); await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ name: 'Schedule', diff --git a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx index 19ea0495c5..c9e4f17fa4 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx @@ -42,7 +42,7 @@ const PromptDetailList = styled(DetailList)` padding: 0px 20px; `; -function ScheduleDetail({ schedule, i18n }) { +function ScheduleDetail({ schedule, i18n, surveyConfig }) { const { id, created, @@ -148,6 +148,7 @@ function ScheduleDetail({ schedule, i18n }) { const { ask_credential_on_launch, + inventory_needed_to_start, ask_diff_mode_on_launch, ask_inventory_on_launch, ask_job_type_on_launch, @@ -160,6 +161,41 @@ function ScheduleDetail({ schedule, i18n }) { survey_enabled, } = launchData || {}; + const missingRequiredInventory = () => { + if (!inventory_needed_to_start || schedule?.summary_fields?.inventory?.id) { + return false; + } + return true; + }; + + const hasMissingSurveyValue = () => { + let missingValues = false; + if (survey_enabled) { + surveyConfig.spec.forEach(question => { + const hasDefaultValue = Boolean(question.default); + if (question.required && !hasDefaultValue) { + const extraDataKeys = Object.keys(schedule?.extra_data); + + const hasMatchingKey = extraDataKeys.includes(question.variable); + Object.values(schedule?.extra_data).forEach(value => { + if (!value || !hasMatchingKey) { + missingValues = true; + } else { + missingValues = false; + } + }); + if (!Object.values(schedule.extra_data).length) { + missingValues = true; + } + } + }); + } + return missingValues; + }; + const isDisabled = Boolean( + missingRequiredInventory() || hasMissingSurveyValue() + ); + const showCredentialsDetail = ask_credential_on_launch && credentials.length > 0; const showInventoryDetail = ask_inventory_on_launch && inventory; @@ -189,14 +225,6 @@ function ScheduleDetail({ schedule, i18n }) { showVerbosityDetail || showVariablesDetail; - const VERBOSITY = { - 0: i18n._(t`0 (Normal)`), - 1: i18n._(t`1 (Verbose)`), - 2: i18n._(t`2 (More Verbose)`), - 3: i18n._(t`3 (Debug)`), - 4: i18n._(t`4 (Connection Debug)`), - }; - if (isLoading) { return ; } @@ -207,7 +235,11 @@ function ScheduleDetail({ schedule, i18n }) { return ( - + @@ -279,12 +311,6 @@ function ScheduleDetail({ schedule, i18n }) { {ask_limit_on_launch && ( )} - {ask_verbosity_on_launch && ( - - )} {showDiffModeDetail && ( ', () => { ); expect(SchedulesAPI.destroy).toHaveBeenCalledTimes(1); }); + test('should have disabled toggle', async () => { + SchedulesAPI.readCredentials.mockResolvedValueOnce({ + data: { + count: 0, + results: [], + }, + }); + JobTemplatesAPI.readLaunch.mockResolvedValueOnce(allPrompts); + await act(async () => { + wrapper = mountWithContexts( + ( + + )} + />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { params: { id: 1 } }, + }, + }, + }, + } + ); + }); + await waitForElement( + wrapper, + 'ScheduleToggle', + el => el.prop('isDisabled') === true + ); + }); }); diff --git a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx index e5faf4dbeb..9f11e8676b 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx @@ -15,7 +15,13 @@ import { parseVariableField } from '../../../util/yaml'; import mergeExtraVars from '../../../util/prompt/mergeExtraVars'; import getSurveyValues from '../../../util/prompt/getSurveyValues'; -function ScheduleEdit({ i18n, schedule, resource }) { +function ScheduleEdit({ + i18n, + schedule, + resource, + launchConfig, + surveyConfig, +}) { const [formSubmitError, setFormSubmitError] = useState(null); const history = useHistory(); const location = useLocation(); @@ -24,8 +30,8 @@ function ScheduleEdit({ i18n, schedule, resource }) { const handleSubmit = async ( values, - launchConfig, - surveyConfig, + launchConfiguration, + surveyConfiguration, scheduleCredentials = [] ) => { const { @@ -36,32 +42,40 @@ function ScheduleEdit({ i18n, schedule, resource }) { interval, startDateTime, timezone, - occurrences, + occurences, runOn, runOnTheDay, runOnTheMonth, runOnDayMonth, runOnDayNumber, endDateTime, - runOnTheOccurrence, + runOnTheOccurence, daysOfWeek, ...submitValues } = values; const { added, removed } = getAddedAndRemoved( - [...resource?.summary_fields.credentials, ...scheduleCredentials], + [...(resource?.summary_fields.credentials || []), ...scheduleCredentials], credentials ); let extraVars; const surveyValues = getSurveyValues(values); const initialExtraVars = - launchConfig?.ask_variables_on_launch && (values.extra_vars || '---'); - if (surveyConfig?.spec) { + launchConfiguration?.ask_variables_on_launch && + (values.extra_vars || '---'); + if (surveyConfiguration?.spec) { extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, surveyValues)); } else { extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, {})); } submitValues.extra_data = extraVars && parseVariableField(extraVars); + + if ( + Object.keys(submitValues.extra_data).length === 0 && + Object.keys(schedule.extra_data).length > 0 + ) { + submitValues.extra_data = schedule.extra_data; + } delete values.extra_vars; if (inventory) { submitValues.inventory = inventory.id; @@ -103,6 +117,8 @@ function ScheduleEdit({ i18n, schedule, resource }) { handleSubmit={handleSubmit} submitError={formSubmitError} resource={resource} + launchConfig={launchConfig} + surveyConfig={surveyConfig} /> diff --git a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx index 404f7334cb..c70eccad6a 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx @@ -7,7 +7,6 @@ import { } from '../../../../testUtils/enzymeHelpers'; import { SchedulesAPI, - JobTemplatesAPI, InventoriesAPI, CredentialsAPI, CredentialTypesAPI, @@ -28,46 +27,6 @@ SchedulesAPI.readZoneInfo.mockResolvedValue({ ], }); -JobTemplatesAPI.readLaunch.mockResolvedValue({ - data: { - can_start_without_user_input: false, - passwords_needed_to_start: [], - ask_scm_branch_on_launch: false, - ask_variables_on_launch: false, - ask_tags_on_launch: false, - ask_diff_mode_on_launch: false, - ask_skip_tags_on_launch: false, - ask_job_type_on_launch: false, - ask_limit_on_launch: false, - ask_verbosity_on_launch: false, - ask_inventory_on_launch: true, - ask_credential_on_launch: true, - survey_enabled: false, - variables_needed_to_start: [], - credential_needed_to_start: true, - inventory_needed_to_start: true, - job_template_data: { - name: 'Demo Job Template', - id: 7, - description: '', - }, - defaults: { - extra_vars: '---', - diff_mode: false, - limit: '', - job_tags: '', - skip_tags: '', - job_type: 'run', - verbosity: 0, - inventory: { - name: null, - id: null, - }, - scm_branch: '', - }, - }, -}); - SchedulesAPI.readCredentials.mockResolvedValue({ data: { results: [ @@ -156,6 +115,44 @@ describe('', () => { ], }, }} + launchConfig={{ + can_start_without_user_input: false, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: true, + ask_credential_on_launch: true, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: true, + inventory_needed_to_start: true, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + defaults: { + extra_vars: '---', + diff_mode: false, + limit: '', + job_tags: '', + skip_tags: '', + job_type: 'run', + verbosity: 0, + inventory: { + name: null, + id: null, + }, + scm_branch: '', + }, + }} + surveyConfig={{}} /> ); }); @@ -202,6 +199,7 @@ describe('', () => { description: 'test description', name: 'Run every 10 minutes 10 times', extra_data: {}, + occurrences: 10, rrule: 'DTSTART;TZID=America/New_York:20200325T103000 RRULE:INTERVAL=10;FREQ=MINUTELY;COUNT=10', }); @@ -265,6 +263,7 @@ describe('', () => { description: 'test description', name: 'Run weekly on mon/wed/fri', extra_data: {}, + occurrences: 1, rrule: `DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=${RRule.MO},${RRule.WE},${RRule.FR}`, }); }); @@ -287,6 +286,7 @@ describe('', () => { description: 'test description', name: 'Run on the first day of the month', extra_data: {}, + occurrences: 1, rrule: 'DTSTART;TZID=America/New_York:20200401T104500 RRULE:INTERVAL=1;FREQ=MONTHLY;BYMONTHDAY=1', }); @@ -312,6 +312,8 @@ describe('', () => { description: 'test description', name: 'Run monthly on the last Tuesday', extra_data: {}, + occurrences: 1, + runOnTheOccurrence: -1, rrule: 'DTSTART;TZID=America/New_York:20200331T110000 RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=-1;BYDAY=TU', }); @@ -336,6 +338,7 @@ describe('', () => { description: 'test description', name: 'Yearly on the first day of March', extra_data: {}, + occurrences: 1, rrule: 'DTSTART;TZID=America/New_York:20200301T000000 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=1', }); @@ -361,6 +364,8 @@ describe('', () => { description: 'test description', name: 'Yearly on the second Friday in April', extra_data: {}, + occurrences: 1, + runOnTheOccurrence: 2, rrule: 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=2;BYDAY=FR;BYMONTH=4', }); @@ -386,6 +391,8 @@ describe('', () => { description: 'test description', name: 'Yearly on the first weekday in October', extra_data: {}, + occurrences: 1, + runOnTheOccurrence: 1, rrule: 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=1;BYDAY=MO,TU,WE,TH,FR;BYMONTH=10', }); @@ -515,6 +522,8 @@ describe('', () => { expect(SchedulesAPI.update).toBeCalledWith(27, { extra_data: {}, name: 'mock schedule', + occurrences: 1, + runOnTheOccurrence: 1, rrule: 'DTSTART;TZID=America/New_York:20210128T141500 RRULE:COUNT=1;FREQ=MINUTELY', skip_tags: '', @@ -590,8 +599,10 @@ describe('', () => { expect(SchedulesAPI.update).toBeCalledWith(27, { description: '', extra_data: {}, - inventory: 702, + occurrences: 1, + runOnTheOccurrence: 1, name: 'foo', + inventory: 702, rrule: 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', }); diff --git a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx index 9bdef2c437..7e5f7d9805 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx @@ -24,6 +24,9 @@ function ScheduleList({ loadSchedules, loadScheduleOptions, hideAddButton, + resource, + launchConfig, + surveyConfig, }) { const [selected, setSelected] = useState([]); @@ -114,6 +117,47 @@ function ScheduleList({ actions && Object.prototype.hasOwnProperty.call(actions, 'POST') && !hideAddButton; + const isTemplate = + resource?.type === 'workflow_job_template' || + resource?.type === 'job_template'; + + const missingRequiredInventory = schedule => { + if ( + !launchConfig.inventory_needed_to_start || + schedule?.summary_fields?.inventory?.id + ) { + return null; + } + return i18n._(t`This schedule is missing an Inventory`); + }; + + const hasMissingSurveyValue = schedule => { + let missingValues; + if (launchConfig.survey_enabled) { + surveyConfig.spec.forEach(question => { + const hasDefaultValue = Boolean(question.default); + if (question.required && !hasDefaultValue) { + const extraDataKeys = Object.keys(schedule?.extra_data); + + const hasMatchingKey = extraDataKeys.includes(question.variable); + Object.values(schedule?.extra_data).forEach(value => { + if (!value || !hasMatchingKey) { + missingValues = true; + } else { + missingValues = false; + } + }); + if (!Object.values(schedule.extra_data).length) { + missingValues = true; + } + } + }); + } + return ( + missingValues && + i18n._(t`This schedule is missing required survey values`) + ); + }; return ( <> @@ -139,6 +183,8 @@ function ScheduleList({ onSelect={() => handleSelect(item)} schedule={item} rowIndex={index} + isMissingInventory={isTemplate && missingRequiredInventory(item)} + isMissingSurvey={isTemplate && hasMissingSurveyValue(item)} /> )} toolbarSearchColumns={[ diff --git a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.test.jsx index 963a51cfcf..2da9d89d6c 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.test.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.test.jsx @@ -32,19 +32,22 @@ describe('ScheduleList', () => { }); describe('read call successful', () => { - beforeAll(async () => { + beforeEach(async () => { await act(async () => { wrapper = mountWithContexts( ); }); wrapper.update(); }); - afterAll(() => { + afterEach(() => { wrapper.unmount(); }); @@ -203,6 +206,60 @@ describe('ScheduleList', () => { wrapper.update(); expect(wrapper.find('ToolbarAddButton').length).toBe(0); }); + test('should show missing resource icon and disabled toggle', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + expect( + wrapper + .find('ScheduleListItem') + .at(4) + .prop('isMissingSurvey') + ).toBe('This schedule is missing required survey values'); + expect(wrapper.find('ExclamationTriangleIcon').length).toBe(5); + expect(wrapper.find('Switch#schedule-5-toggle').prop('isDisabled')).toBe( + true + ); + }); + test('should show missing resource icon and disabled toggle', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + + expect( + wrapper + .find('ScheduleListItem') + .at(3) + .prop('isMissingInventory') + ).toBe('This schedule is missing an Inventory'); + expect(wrapper.find('ExclamationTriangleIcon').length).toBe(4); + expect(wrapper.find('Switch#schedule-3-toggle').prop('isDisabled')).toBe( + true + ); + }); }); describe('read call unsuccessful', () => { diff --git a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.jsx index 6e71a64e18..b642f28638 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.jsx @@ -4,16 +4,33 @@ import { bool, func } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Link } from 'react-router-dom'; -import { Button } from '@patternfly/react-core'; +import { Button, Tooltip } from '@patternfly/react-core'; import { Tr, Td } from '@patternfly/react-table'; -import { PencilAltIcon } from '@patternfly/react-icons'; +import { + PencilAltIcon, + ExclamationTriangleIcon as PFExclamationTriangleIcon, +} from '@patternfly/react-icons'; +import styled from 'styled-components'; import { DetailList, Detail } from '../../DetailList'; import { ActionsTd, ActionItem } from '../../PaginatedTable'; import { ScheduleToggle } from '..'; import { Schedule } from '../../../types'; import { formatDateString } from '../../../util/dates'; -function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) { +const ExclamationTriangleIcon = styled(PFExclamationTriangleIcon)` + color: #c9190b; + margin-left: 20px; +`; + +function ScheduleListItem({ + i18n, + rowIndex, + isSelected, + onSelect, + schedule, + isMissingInventory, + isMissingSurvey, +}) { const labelId = `check-action-${schedule.id}`; const jobTypeLabels = { @@ -45,6 +62,7 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) { default: break; } + const isDisabled = Boolean(isMissingInventory || isMissingSurvey); return ( @@ -61,6 +79,18 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) { {schedule.name} + {Boolean(isMissingInventory || isMissingSurvey) && ( + + ( +
    {message}
    + ))} + position="right" + > + +
    +
    + )} { @@ -80,7 +110,7 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) { )} - + { describe('User has edit permissions', () => { beforeAll(() => { wrapper = mountWithContexts( - - - - -
    + ); }); @@ -118,6 +116,9 @@ describe('ScheduleListItem', () => { .simulate('change'); expect(onSelect).toHaveBeenCalledTimes(1); }); + test('Toggle button is enabled', () => { + expect(wrapper.find('ScheduleToggle').prop('isDisabled')).toBe(false); + }); }); describe('User has read-only permissions', () => { @@ -186,4 +187,35 @@ describe('ScheduleListItem', () => { ).toBe(true); }); }); + describe('schedule has missing prompt data', () => { + beforeAll(() => { + wrapper = mountWithContexts( + + ); + }); + + afterAll(() => { + wrapper.unmount(); + }); + + test('should show missing resource icon', () => { + expect(wrapper.find('ExclamationTriangleIcon').length).toBe(1); + expect(wrapper.find('ScheduleToggle').prop('isDisabled')).toBe(true); + }); + }); }); diff --git a/awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.jsx b/awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.jsx index cb15696415..cc9d333fa3 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.jsx @@ -8,7 +8,7 @@ import ErrorDetail from '../../ErrorDetail'; import useRequest from '../../../util/useRequest'; import { SchedulesAPI } from '../../../api'; -function ScheduleToggle({ schedule, onToggle, className, i18n }) { +function ScheduleToggle({ schedule, onToggle, className, i18n, isDisabled }) { const [isEnabled, setIsEnabled] = useState(schedule.enabled); const [showError, setShowError] = useState(false); @@ -55,7 +55,9 @@ function ScheduleToggle({ schedule, onToggle, className, i18n }) { labelOff={i18n._(t`Off`)} isChecked={isEnabled} isDisabled={ - isLoading || !schedule.summary_fields.user_capabilities.edit + isLoading || + !schedule.summary_fields.user_capabilities.edit || + isDisabled } onChange={toggleSchedule} aria-label={i18n._(t`Toggle schedule`)} diff --git a/awx/ui_next/src/components/Schedule/Schedules.jsx b/awx/ui_next/src/components/Schedule/Schedules.jsx index f0daa8989b..22f429dd29 100644 --- a/awx/ui_next/src/components/Schedule/Schedules.jsx +++ b/awx/ui_next/src/components/Schedule/Schedules.jsx @@ -10,6 +10,8 @@ function Schedules({ loadScheduleOptions, loadSchedules, setBreadcrumb, + launchConfig, + surveyConfig, resource, }) { const match = useRouteMatch(); @@ -17,14 +19,27 @@ function Schedules({ return ( - + - + diff --git a/awx/ui_next/src/components/Schedule/data.schedules.json b/awx/ui_next/src/components/Schedule/data.schedules.json index 13ef941811..75b1e15ebf 100644 --- a/awx/ui_next/src/components/Schedule/data.schedules.json +++ b/awx/ui_next/src/components/Schedule/data.schedules.json @@ -8,6 +8,7 @@ "rrule": "DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1", "id": 1, + "extra_data":{}, "summary_fields": { "unified_job_template": { "id": 6, @@ -27,6 +28,7 @@ "rrule": "DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1", "id": 2, + "extra_data":{}, "summary_fields": { "unified_job_template": { "id": 7, @@ -46,6 +48,7 @@ "rrule": "DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1", "id": 3, + "extra_data":{}, "summary_fields": { "unified_job_template": { "id": 8, @@ -65,6 +68,7 @@ "rrule": "DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1", "id": 4, + "extra_data":{}, "summary_fields": { "unified_job_template": { "id": 9, @@ -84,6 +88,7 @@ "rrule": "DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1", "id": 5, + "extra_data":{"novalue":null}, "summary_fields": { "unified_job_template": { "id": 10, @@ -103,4 +108,4 @@ "next_run": "2020-02-20T05:00:00Z" } ] -} \ No newline at end of file +} diff --git a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx index 35504df3b6..3088996a3d 100644 --- a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx @@ -12,11 +12,7 @@ import { ActionGroup, } from '@patternfly/react-core'; import { Config } from '../../../contexts/Config'; -import { - SchedulesAPI, - JobTemplatesAPI, - WorkflowJobTemplatesAPI, -} from '../../../api'; +import { SchedulesAPI } from '../../../api'; import AnsibleSelect from '../../AnsibleSelect'; import ContentError from '../../ContentError'; import ContentLoading from '../../ContentLoading'; @@ -194,6 +190,8 @@ function ScheduleForm({ schedule, submitError, resource, + launchConfig, + surveyConfig, ...rest }) { const [isWizardOpen, setIsWizardOpen] = useState(false); @@ -210,37 +208,15 @@ function ScheduleForm({ const isTemplate = resource.type === 'workflow_job_template' || resource.type === 'job_template'; - const isWorkflowJobTemplate = - isTemplate && resource.type === 'workflow_job_template'; - const { request: loadScheduleData, error: contentError, contentLoading, - result: { zoneOptions, surveyConfig, launchConfig, credentials }, + result: { zoneOptions, credentials }, } = useRequest( useCallback(async () => { - const readLaunch = - isTemplate && - (isWorkflowJobTemplate - ? WorkflowJobTemplatesAPI.readLaunch(resource.id) - : JobTemplatesAPI.readLaunch(resource.id)); - const [{ data }, { data: launchConfiguration }] = await Promise.all([ - SchedulesAPI.readZoneInfo(), - readLaunch, - ]); + const { data } = await SchedulesAPI.readZoneInfo(); - const readSurvey = isWorkflowJobTemplate - ? WorkflowJobTemplatesAPI.readSurvey(resource.id) - : JobTemplatesAPI.readSurvey(resource.id); - - let surveyConfiguration = null; - - if (isTemplate && launchConfiguration.survey_enabled) { - const { data: survey } = await readSurvey; - - surveyConfiguration = survey; - } let creds; if (schedule.id) { const { @@ -249,37 +225,6 @@ function ScheduleForm({ creds = results; } - const missingRequiredInventory = Boolean( - !resource.inventory && !schedule?.summary_fields?.inventory.id - ); - let missingRequiredSurvey = false; - - if ( - schedule.id && - isTemplate && - !launchConfiguration?.can_start_without_user_input - ) { - missingRequiredSurvey = surveyConfiguration?.spec?.every(question => { - let hasValue; - if (Object.keys(schedule)?.length === 0) { - hasValue = true; - } - Object.entries(schedule?.extra_data).forEach(([key, value]) => { - if ( - question.required && - question.variable === key && - value.length > 0 - ) { - hasValue = false; - } - }); - return hasValue; - }); - } - if (missingRequiredInventory || missingRequiredSurvey) { - setIsSaveDisabled(true); - } - const zones = data.map(zone => { return { value: zone.name, @@ -290,18 +235,61 @@ function ScheduleForm({ return { zoneOptions: zones, - surveyConfig: surveyConfiguration || {}, - launchConfig: launchConfiguration, credentials: creds || [], }; - }, [isTemplate, isWorkflowJobTemplate, resource, schedule]), + }, [schedule]), { zonesOptions: [], - surveyConfig: {}, - launchConfig: {}, credentials: [], } ); + const missingRequiredInventory = useCallback(() => { + let missingInventory = false; + if ( + launchConfig.inventory_needed_to_start && + !schedule?.summary_fields?.inventory?.id + ) { + missingInventory = true; + } + return missingInventory; + }, [launchConfig, schedule]); + + const hasMissingSurveyValue = useCallback(() => { + let missingValues = false; + if (launchConfig?.survey_enabled) { + surveyConfig.spec.forEach(question => { + const hasDefaultValue = Boolean(question.default); + const hasSchedule = Object.keys(schedule).length; + const isRequired = question.required; + if (isRequired && !hasDefaultValue) { + if (!hasSchedule) { + missingValues = true; + } else { + const hasMatchingKey = Object.keys(schedule?.extra_data).includes( + question.variable + ); + Object.values(schedule?.extra_data).forEach(value => { + if (!value || !hasMatchingKey) { + missingValues = true; + } else { + missingValues = false; + } + }); + if (!Object.values(schedule.extra_data).length) { + missingValues = true; + } + } + } + }); + } + return missingValues; + }, [launchConfig, schedule, surveyConfig]); + + useEffect(() => { + if (isTemplate && (missingRequiredInventory() || hasMissingSurveyValue())) { + setIsSaveDisabled(true); + } + }, [isTemplate, hasMissingSurveyValue, missingRequiredInventory]); useEffect(() => { loadScheduleData(); @@ -532,6 +520,7 @@ function ScheduleForm({ > {i18n._(t`Save`)} + {isTemplate && showPromptButton && (