From 8cae728ea01478f4b8f83ca52ddcd53bdad16abf Mon Sep 17 00:00:00 2001 From: dejongm Date: Thu, 7 Jan 2021 10:27:03 -0500 Subject: [PATCH 01/66] 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 02/66] 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 03/66] 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 04/66] 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 05/66] 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 06/66] 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 07/66] 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 08/66] 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 09/66] 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 10/66] 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 11/66] 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 12/66] 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 13/66] 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 14/66] 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 61c0beccffbad3ac5ce53049344c1475a1f13479 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 26 Jan 2021 14:58:52 -0500 Subject: [PATCH 15/66] 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 16/66] 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 17/66] 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 && (