diff --git a/webpack/JobWizard/JobWizard.js b/webpack/JobWizard/JobWizard.js index 7ee3c9de7..fbad00d30 100644 --- a/webpack/JobWizard/JobWizard.js +++ b/webpack/JobWizard/JobWizard.js @@ -3,43 +3,58 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Wizard } from '@patternfly/react-core'; import { get } from 'foremanReact/redux/API'; -import { translate as __ } from 'foremanReact/common/I18n'; import history from 'foremanReact/history'; import CategoryAndTemplate from './steps/CategoryAndTemplate/'; import { AdvancedFields } from './steps/AdvancedFields/AdvancedFields'; -import { JOB_TEMPLATE } from './JobWizardConstants'; +import { JOB_TEMPLATE, WIZARD_TITLES } from './JobWizardConstants'; import { selectTemplateError } from './JobWizardSelectors'; import Schedule from './steps/Schedule/'; +import HostsAndInputs from './steps/HostsAndInputs/'; import './JobWizard.scss'; export const JobWizard = () => { const [jobTemplateID, setJobTemplateID] = useState(null); const [category, setCategory] = useState(''); const [advancedValues, setAdvancedValues] = useState({}); + const [templateValues, setTemplateValues] = useState({}); // TODO use templateValues in advanced fields - description https://github.com/theforeman/foreman_remote_execution/pull/605 + const [selectedHosts, setSelectedHosts] = useState(['host1', 'host2']); const dispatch = useDispatch(); const setDefaults = useCallback( ({ data: { + template_inputs, advanced_template_inputs, effective_user, job_template: { executionTimeoutInterval, description_format }, }, }) => { const advancedTemplateValues = {}; + const defaultTemplateValues = {}; + const inputs = template_inputs; const advancedInputs = advanced_template_inputs; - if (advancedInputs) { - advancedInputs.forEach(input => { - advancedTemplateValues[input.name] = input?.default || ''; + if (inputs) { + setTemplateValues(() => { + inputs.forEach(input => { + defaultTemplateValues[input.name] = input?.default || ''; + }); + return defaultTemplateValues; }); } - setAdvancedValues(currentAdvancedValues => ({ - ...currentAdvancedValues, - effectiveUserValue: effective_user?.value || '', - timeoutToKill: executionTimeoutInterval || '', - templateValues: advancedTemplateValues, - description: description_format || '', - })); + setAdvancedValues(currentAdvancedValues => { + if (advancedInputs) { + advancedInputs.forEach(input => { + advancedTemplateValues[input.name] = input?.default || ''; + }); + } + return { + ...currentAdvancedValues, + effectiveUserValue: effective_user?.value || '', + timeoutToKill: executionTimeoutInterval || '', + templateValues: advancedTemplateValues, + description: description_format || '', + }; + }); }, [] ); @@ -59,7 +74,7 @@ export const JobWizard = () => { const isTemplate = !templateError && !!jobTemplateID; const steps = [ { - name: __('Category and Template'), + name: WIZARD_TITLES.categoryAndTemplate, component: ( { ), }, { - name: __('Target Hosts'), - component:

Target Hosts

, + name: WIZARD_TITLES.hostsAndInputs, + component: ( + + ), canJumpTo: isTemplate, }, { - name: __('Advanced Fields'), + name: WIZARD_TITLES.advanced, component: ( { canJumpTo: isTemplate, }, { - name: __('Schedule'), + name: WIZARD_TITLES.schedule, component: , canJumpTo: isTemplate, }, { - name: __('Review Details'), + name: WIZARD_TITLES.review, component:

Review Details

, nextButtonText: 'Run', canJumpTo: isTemplate, diff --git a/webpack/JobWizard/JobWizard.scss b/webpack/JobWizard/JobWizard.scss index 23fefa7c5..bcfba5a17 100644 --- a/webpack/JobWizard/JobWizard.scss +++ b/webpack/JobWizard/JobWizard.scss @@ -1,5 +1,10 @@ .job-wizard { + .wizard-title { + margin-bottom: 25px; + } + .pf-c-wizard__main { + overflow: visible; z-index: calc( var(--pf-c-wizard__footer--ZIndex) + 1 ); // So the select box can be shown above the wizard footer @@ -41,6 +46,9 @@ } } + .hosts-chip-group { + margin-top: 8px; + } .schedule-tab { input[type='radio'], input[type='checkbox'] { @@ -50,4 +58,8 @@ text-align: start; } } + textarea { + min-height: 40px; + min-width: 100px; + } } diff --git a/webpack/JobWizard/JobWizardConstants.js b/webpack/JobWizard/JobWizardConstants.js index 83b4b16de..0ddf20105 100644 --- a/webpack/JobWizard/JobWizardConstants.js +++ b/webpack/JobWizard/JobWizardConstants.js @@ -14,3 +14,11 @@ export const repeatTypes = { daily: __('Daily'), hourly: __('Hourly'), }; + +export const WIZARD_TITLES = { + categoryAndTemplate: __('Category and Template'), + hostsAndInputs: __('Target hosts and inputs'), + advanced: __('Advanced Fields'), + schedule: __('Schedule'), + review: __('Review Details'), +}; diff --git a/webpack/JobWizard/__tests__/fixtures.js b/webpack/JobWizard/__tests__/fixtures.js index af3759330..2048eb421 100644 --- a/webpack/JobWizard/__tests__/fixtures.js +++ b/webpack/JobWizard/__tests__/fixtures.js @@ -93,6 +93,14 @@ export const testSetup = (selectors, api) => { jest.spyOn(selectors, 'selectJobCategories'); jest.spyOn(selectors, 'selectJobCategoriesStatus'); + jest.spyOn(selectors, 'selectTemplateInputs'); + jest.spyOn(selectors, 'selectAdvancedTemplateInputs'); + selectors.selectTemplateInputs.mockImplementation( + () => jobTemplateResponse.template_inputs + ); + selectors.selectAdvancedTemplateInputs.mockImplementation( + () => jobTemplateResponse.advanced_template_inputs + ); selectors.selectJobCategories.mockImplementation(() => jobCategories); selectors.selectJobTemplates.mockImplementation(() => [ jobTemplate, diff --git a/webpack/JobWizard/__tests__/integration.test.js b/webpack/JobWizard/__tests__/integration.test.js index 2eae60489..93b3916ef 100644 --- a/webpack/JobWizard/__tests__/integration.test.js +++ b/webpack/JobWizard/__tests__/integration.test.js @@ -5,6 +5,7 @@ import { render, fireEvent, screen, act } from '@testing-library/react'; import * as api from 'foremanReact/redux/API'; import { JobWizard } from '../JobWizard'; import * as selectors from '../JobWizardSelectors'; +import { WIZARD_TITLES } from '../JobWizardConstants'; import { testSetup, mockApi, @@ -62,13 +63,8 @@ describe('Job wizard fill', () => { ); - const steps = [ - 'Target Hosts', - 'Advanced Fields', - 'Schedule', - 'Review Details', - 'Category and Template', - ]; + const titles = Object.values(WIZARD_TITLES); + const steps = [titles[1], titles[0], ...titles.slice(2)]; // the first title is selected at the beggining // eslint-disable-next-line no-unused-vars for await (const step of steps) { const stepSelector = screen.getByText(step); diff --git a/webpack/JobWizard/steps/AdvancedFields/AdvancedFields.js b/webpack/JobWizard/steps/AdvancedFields/AdvancedFields.js index c8abfe129..ad0d44c50 100644 --- a/webpack/JobWizard/steps/AdvancedFields/AdvancedFields.js +++ b/webpack/JobWizard/steps/AdvancedFields/AdvancedFields.js @@ -1,8 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import { Title, Form } from '@patternfly/react-core'; -import { translate as __ } from 'foremanReact/common/I18n'; +import { Form } from '@patternfly/react-core'; import { selectEffectiveUser, selectAdvancedTemplateInputs, @@ -19,6 +18,8 @@ import { TemplateInputsFields, } from './Fields'; import { DescriptionField } from './DescriptionField'; +import { WIZARD_TITLES } from '../../JobWizardConstants'; +import { WizardTitle } from '../form/WizardTitle'; export const AdvancedFields = ({ advancedValues, setAdvancedValues }) => { const effectiveUser = useSelector(selectEffectiveUser); @@ -26,9 +27,10 @@ export const AdvancedFields = ({ advancedValues, setAdvancedValues }) => { const templateInputs = useSelector(selectTemplateInputs); return ( <> - - {__('Advanced Fields')} - +
jobTemplate.effective_user ); -selectors.selectTemplateInputs.mockImplementation( - () => jobTemplate.template_inputs -); - -selectors.selectAdvancedTemplateInputs.mockImplementation( - () => jobTemplate.advanced_template_inputs -); describe('AdvancedFields', () => { it('should save data between steps for advanced fields', async () => { const wrapper = mount( @@ -72,7 +64,7 @@ describe('AdvancedFields', () => { .simulate('click'); expect(wrapper.find('.pf-c-wizard__nav-link.pf-m-current').text()).toEqual( - 'Target Hosts' + 'Target hosts and inputs' ); wrapper .find('.pf-c-wizard__nav-link') @@ -91,7 +83,7 @@ describe('AdvancedFields', () => { ); await act(async () => { - fireEvent.click(screen.getByText('Advanced Fields')); + fireEvent.click(screen.getByText(WIZARD_TITLES.advanced)); }); const searchValue = 'search test'; const textValue = 'I am a text'; @@ -108,7 +100,7 @@ describe('AdvancedFields', () => { fireEvent.click(selectField); await act(async () => { await fireEvent.click(screen.getByText('option 2')); - fireEvent.click(screen.getAllByText('Advanced Fields')[0]); // to remove focus + fireEvent.click(screen.getAllByText(WIZARD_TITLES.advanced)[0]); // to remove focus await fireEvent.change(textField, { target: { value: textValue }, }); @@ -128,9 +120,11 @@ describe('AdvancedFields', () => { expect(searchField.value).toBe(searchValue); expect(dateField.value).toBe(dateValue); await act(async () => { - fireEvent.click(screen.getByText('Category and Template')); + fireEvent.click(screen.getByText(WIZARD_TITLES.categoryAndTemplate)); }); - expect(screen.getAllByText('Category and Template')).toHaveLength(3); + expect(screen.getAllByText(WIZARD_TITLES.categoryAndTemplate)).toHaveLength( + 3 + ); await act(async () => { fireEvent.click(screen.getByText('Advanced Fields')); diff --git a/webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.js b/webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.js index a8e7474bf..5394d1f5a 100644 --- a/webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.js +++ b/webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.js @@ -1,9 +1,11 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Title, Text, TextVariants, Form, Alert } from '@patternfly/react-core'; +import { Text, TextVariants, Form, Alert } from '@patternfly/react-core'; import { translate as __ } from 'foremanReact/common/I18n'; import { SelectField } from '../form/SelectField'; import { GroupedSelectField } from '../form/GroupedSelectField'; +import { WizardTitle } from '../form/WizardTitle'; +import { WIZARD_TITLES } from '../../JobWizardConstants'; export const CategoryAndTemplate = ({ jobCategories, @@ -40,7 +42,7 @@ export const CategoryAndTemplate = ({ const isError = !!(categoryError || allTemplatesError || templateError); return ( <> - {__('Category and Template')} + {__('All fields are required.')} { await act(async () => { await fireEvent.click(screen.getByText('Puppet')); }); - fireEvent.click(screen.getAllByText('Category and Template')[0]); // to remove focus + fireEvent.click(screen.getAllByText(WIZARD_TITLES.categoryAndTemplate)[0]); // to remove focus expect( screen.queryAllByLabelText('Ansible Commands', { selector: 'button' }) ).toHaveLength(0); @@ -47,7 +48,7 @@ describe('Category And Template', () => { await act(async () => { await fireEvent.click(screen.getByText('template2')); }); - fireEvent.click(screen.getAllByText('Category and Template')[0]); // to remove focus + fireEvent.click(screen.getAllByText(WIZARD_TITLES.categoryAndTemplate)[0]); // to remove focus expect( screen.queryAllByDisplayValue('template1', { selector: 'button' }) ).toHaveLength(0); diff --git a/webpack/JobWizard/steps/HostsAndInputs/SelectedChips.js b/webpack/JobWizard/steps/HostsAndInputs/SelectedChips.js new file mode 100644 index 000000000..7ba0d1e02 --- /dev/null +++ b/webpack/JobWizard/steps/HostsAndInputs/SelectedChips.js @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Chip, ChipGroup } from '@patternfly/react-core'; + +export const SelectedChips = ({ selected, setSelected }) => { + const deleteItem = itemToRemove => { + setSelected(oldSelected => + oldSelected.filter(item => item !== itemToRemove) + ); + }; + return ( + + {selected.map(chip => ( + deleteItem(chip)}> + {chip} + + ))} + + ); +}; + +SelectedChips.propTypes = { + selected: PropTypes.array.isRequired, + setSelected: PropTypes.func.isRequired, +}; diff --git a/webpack/JobWizard/steps/HostsAndInputs/TemplateInputs.js b/webpack/JobWizard/steps/HostsAndInputs/TemplateInputs.js new file mode 100644 index 000000000..c0b84a3aa --- /dev/null +++ b/webpack/JobWizard/steps/HostsAndInputs/TemplateInputs.js @@ -0,0 +1,23 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { formatter } from '../form/Formatter'; + +export const TemplateInputs = ({ inputs, value, setValue }) => { + if (inputs.length) + return inputs.map(input => formatter(input, value, setValue)); + return ( +

+ {__('There are no available input fields for the selected template.')} +

+ ); +}; +TemplateInputs.propTypes = { + inputs: PropTypes.array.isRequired, + value: PropTypes.object, + setValue: PropTypes.func.isRequired, +}; + +TemplateInputs.defaultProps = { + value: {}, +}; diff --git a/webpack/JobWizard/steps/HostsAndInputs/__tests__/SelectedChips.test.js b/webpack/JobWizard/steps/HostsAndInputs/__tests__/SelectedChips.test.js new file mode 100644 index 000000000..af6a750cc --- /dev/null +++ b/webpack/JobWizard/steps/HostsAndInputs/__tests__/SelectedChips.test.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { fireEvent, screen, render, act } from '@testing-library/react'; +import * as api from 'foremanReact/redux/API'; +import { JobWizard } from '../../../JobWizard'; +import * as selectors from '../../../JobWizardSelectors'; +import { testSetup, mockApi } from '../../../__tests__/fixtures'; + +const store = testSetup(selectors, api); +mockApi(api); + +describe('TemplateInputs', () => { + it('should save data between steps for template input fields', async () => { + render( + + + + ); + await act(async () => { + await fireEvent.click( + screen.getByText('Target hosts and inputs', { selector: 'button' }) + ); + }); + + expect( + screen.getAllByLabelText('host2', { selector: 'button' }) + ).toHaveLength(1); + const chip1 = screen.getByLabelText('host1', { selector: 'button' }); + fireEvent.click(chip1); + expect( + screen.queryAllByLabelText('host1', { selector: 'button' }) + ).toHaveLength(0); + expect( + screen.queryAllByLabelText('host2', { selector: 'button' }) + ).toHaveLength(1); + }); +}); diff --git a/webpack/JobWizard/steps/HostsAndInputs/__tests__/TemplateInputs.test.js b/webpack/JobWizard/steps/HostsAndInputs/__tests__/TemplateInputs.test.js new file mode 100644 index 000000000..d58dc85f5 --- /dev/null +++ b/webpack/JobWizard/steps/HostsAndInputs/__tests__/TemplateInputs.test.js @@ -0,0 +1,50 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { fireEvent, screen, render, act } from '@testing-library/react'; +import * as api from 'foremanReact/redux/API'; +import { JobWizard } from '../../../JobWizard'; +import * as selectors from '../../../JobWizardSelectors'; +import { testSetup, mockApi } from '../../../__tests__/fixtures'; +import { WIZARD_TITLES } from '../../../JobWizardConstants'; + +const store = testSetup(selectors, api); +mockApi(api); + +describe('TemplateInputs', () => { + it('should save data between steps for template input fields', async () => { + render( + + + + ); + await act(async () => { + fireEvent.click(screen.getByText(WIZARD_TITLES.hostsAndInputs)); + }); + const textValue = 'I am a plain text'; + const textField = screen.getByLabelText('plain hidden', { + selector: 'textarea', + }); + + await act(async () => { + await fireEvent.change(textField, { + target: { value: textValue }, + }); + }); + expect( + screen.getByLabelText('plain hidden', { + selector: 'textarea', + }).value + ).toBe(textValue); + await act(async () => { + fireEvent.click(screen.getByText(WIZARD_TITLES.categoryAndTemplate)); + }); + expect(screen.getAllByText(WIZARD_TITLES.categoryAndTemplate)).toHaveLength( + 3 + ); + + await act(async () => { + fireEvent.click(screen.getByText(WIZARD_TITLES.hostsAndInputs)); + }); + expect(textField.value).toBe(textValue); + }); +}); diff --git a/webpack/JobWizard/steps/HostsAndInputs/index.js b/webpack/JobWizard/steps/HostsAndInputs/index.js new file mode 100644 index 000000000..a33a84f91 --- /dev/null +++ b/webpack/JobWizard/steps/HostsAndInputs/index.js @@ -0,0 +1,66 @@ +import React, { useState } from 'react'; +import { Button, Form, FormGroup } from '@patternfly/react-core'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { selectTemplateInputs } from '../../JobWizardSelectors'; +import { SelectField } from '../form/SelectField'; +import { SelectedChips } from './SelectedChips'; +import { TemplateInputs } from './TemplateInputs'; +import { WIZARD_TITLES } from '../../JobWizardConstants'; +import { WizardTitle } from '../form/WizardTitle'; + +const HostsAndInputs = ({ + templateValues, + setTemplateValues, + selectedHosts, + setSelectedHosts, +}) => { + const templateInputs = useSelector(selectTemplateInputs); + const hostMethods = [ + __('Hosts'), + __('Host collection'), + __('Host group'), + __('Search query'), + ]; + const [hostMethod, setHostMethod] = useState(hostMethods[0]); + return ( + <> + + + + + + + + {__('Apply to')}{' '} + + + + + + ); +}; + +HostsAndInputs.propTypes = { + templateValues: PropTypes.object.isRequired, + setTemplateValues: PropTypes.func.isRequired, + selectedHosts: PropTypes.array.isRequired, + setSelectedHosts: PropTypes.func.isRequired, +}; + +export default HostsAndInputs; diff --git a/webpack/JobWizard/steps/Schedule/index.js b/webpack/JobWizard/steps/Schedule/index.js index d59ef56dc..fdc14c09e 100644 --- a/webpack/JobWizard/steps/Schedule/index.js +++ b/webpack/JobWizard/steps/Schedule/index.js @@ -1,11 +1,12 @@ import React, { useState } from 'react'; -import { Title, Button, Form } from '@patternfly/react-core'; +import { Button, Form } from '@patternfly/react-core'; import { translate as __ } from 'foremanReact/common/I18n'; import { ScheduleType } from './ScheduleType'; import { RepeatOn } from './RepeatOn'; import { QueryType } from './QueryType'; import { StartEndDates } from './StartEndDates'; -import { repeatTypes } from '../../JobWizardConstants'; +import { repeatTypes, WIZARD_TITLES } from '../../JobWizardConstants'; +import { WizardTitle } from '../form/WizardTitle'; const Schedule = () => { const [repeatType, setRepeatType] = useState(repeatTypes.noRepeat); @@ -14,27 +15,29 @@ const Schedule = () => { const [ends, setEnds] = useState(''); return ( -
- {__('Schedule')} - + <> + + + - - - - - + + + + + + ); }; diff --git a/webpack/JobWizard/steps/form/Formatter.js b/webpack/JobWizard/steps/form/Formatter.js index 7173ad22f..b12551da2 100644 --- a/webpack/JobWizard/steps/form/Formatter.js +++ b/webpack/JobWizard/steps/form/Formatter.js @@ -22,11 +22,12 @@ const TemplateSearchField = ({ setValue({ ...values, [name]: searchQuery }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchQuery]); + const id = name.replace(/ /g, '-'); return ( @@ -54,16 +55,16 @@ export const formatter = (input, values, setValue) => { const { name, required, hidden_value: hidden } = input; const labelText = input.description; const value = values[name]; - + const id = name.replace(/ /g, '-'); if (isSelectType) { const options = input.options.split(/\r?\n/).map(option => option.trim()); return ( { key={name} label={name} labelIcon={helpLabel(labelText, name)} - fieldId={name} + fieldId={id} isRequired={required} >