Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes #32667 - add job wizard form validation #622

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
31 changes: 25 additions & 6 deletions webpack/JobWizard/JobWizard.js
Expand Up @@ -11,15 +11,16 @@ import {
WIZARD_TITLES,
initialScheduleState,
} from './JobWizardConstants';
import { selectTemplateError } from './JobWizardSelectors';
import { selectTemplateError, selectJobTemplate } from './JobWizardSelectors';
import Schedule from './steps/Schedule/';
import HostsAndInputs from './steps/HostsAndInputs/';
import { useValidation } from './validation';
import './JobWizard.scss';

export const JobWizard = () => {
const [jobTemplateID, setJobTemplateID] = useState(null);
const [category, setCategory] = useState('');
const [advancedValues, setAdvancedValues] = useState({});
const [advancedValues, setAdvancedValues] = useState({ templateValues: {} });
const [templateValues, setTemplateValues] = useState({}); // TODO use templateValues in advanced fields - description https://github.com/theforeman/foreman_remote_execution/pull/605
const [scheduleValue, setScheduleValue] = useState(initialScheduleState);
const [selectedTargets, setSelectedTargets] = useState({
Expand Down Expand Up @@ -90,8 +91,15 @@ export const JobWizard = () => {
}
}, [jobTemplateID, setDefaults, dispatch]);

const [valid, setValid] = useValidation({
advancedValues,
templateValues,
});
const templateError = !!useSelector(selectTemplateError);
const isTemplate = !templateError && !!jobTemplateID;
const templateResponse = useSelector(selectJobTemplate);
const isTemplate =
!templateError && !!jobTemplateID && templateResponse.job_template;

const steps = [
{
name: WIZARD_TITLES.categoryAndTemplate,
Expand All @@ -103,6 +111,7 @@ export const JobWizard = () => {
setCategory={setCategory}
/>
),
enableNext: isTemplate,
},
{
name: WIZARD_TITLES.hostsAndInputs,
Expand All @@ -117,6 +126,7 @@ export const JobWizard = () => {
/>
),
canJumpTo: isTemplate,
enableNext: isTemplate && valid.hostsAndInputs,
},
{
name: WIZARD_TITLES.advanced,
Expand All @@ -132,23 +142,32 @@ export const JobWizard = () => {
templateValues={templateValues}
/>
),
canJumpTo: isTemplate,
canJumpTo: isTemplate && valid.hostsAndInputs,
MariaAga marked this conversation as resolved.
Show resolved Hide resolved
enableNext: isTemplate && valid.hostsAndInputs && valid.advanced,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user got here, it probably means that valid.hostsAndInputs is true.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But a user can go a step back and enter invalid values

},
{
name: WIZARD_TITLES.schedule,
component: (
<Schedule
scheduleValue={scheduleValue}
setScheduleValue={setScheduleValue}
setValid={newValue => {
setValid(currentValid => ({ ...currentValid, schedule: newValue }));
}}
/>
),
canJumpTo: isTemplate,
canJumpTo: isTemplate && valid.hostsAndInputs && valid.advanced,
enableNext:
isTemplate && valid.hostsAndInputs && valid.advanced && valid.schedule,
},
{
name: WIZARD_TITLES.review,
component: <p>Review Details</p>,
nextButtonText: 'Run',
canJumpTo: isTemplate,
canJumpTo:
isTemplate && valid.hostsAndInputs && valid.advanced && valid.schedule,
enableNext:
isTemplate && valid.hostsAndInputs && valid.advanced && valid.schedule,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, I think that for clicking next / run we can check just for the validity of the step itself

},
];
return (
Expand Down
2 changes: 1 addition & 1 deletion webpack/JobWizard/__tests__/fixtures.js
Expand Up @@ -120,7 +120,7 @@ export const testSetup = (selectors, api) => {
jobTemplate,
{ ...jobTemplate, id: 2, name: 'template2' },
]);

selectors.selectJobTemplate.mockImplementation(() => jobTemplateResponse);
const mockStore = configureMockStore([]);
const store = mockStore({
ForemanTasksTask: {
Expand Down
31 changes: 19 additions & 12 deletions webpack/JobWizard/__tests__/integration.test.js
Expand Up @@ -17,45 +17,52 @@ import {

const store = testSetup(selectors, api);

selectors.selectJobTemplate.mockImplementation(() => {});

api.get.mockImplementation(({ handleSuccess, ...action }) => {
if (action.key === 'JOB_CATEGORIES') {
handleSuccess && handleSuccess({ data: { job_categories: jobCategories } });
}
return { type: 'get', ...action };
});
describe('Job wizard fill', () => {
it('should select template', async () => {
api.get.mockImplementation(({ handleSuccess, ...action }) => {
if (action.key === 'JOB_CATEGORIES') {
handleSuccess &&
handleSuccess({ data: { job_categories: jobCategories } });
} else if (action.key === 'JOB_TEMPLATE') {
handleSuccess &&
handleSuccess({
data: jobTemplate,
});
}
return { type: 'get', ...action };
});
selectors.selectJobTemplate.mockRestore();
jest.spyOn(selectors, 'selectJobTemplate');
selectors.selectJobTemplate.mockImplementation(() => ({}));
const wrapper = mount(
<Provider store={store}>
<JobWizard advancedValues={{}} setAdvancedValues={jest.fn()} />
<JobWizard />
</Provider>
);
expect(wrapper.find('.pf-c-wizard__nav-link.pf-m-disabled')).toHaveLength(
4
);
selectors.selectJobCategoriesStatus.mockImplementation(() => 'RESOLVED');
expect(store.getActions()).toMatchSnapshot('initial');

selectors.selectJobTemplate.mockRestore();
jest.spyOn(selectors, 'selectJobTemplate');
selectors.selectJobTemplate.mockImplementation(() => jobTemplate);
wrapper.find('.pf-c-button.pf-c-select__toggle-button').simulate('click');
await act(async () => {
await wrapper
.find('.pf-c-select__menu-item')
.first()
.simulate('click');
await wrapper.update();
});
expect(store.getActions().slice(-1)).toMatchSnapshot('select template');
wrapper.update();
expect(wrapper.find('.pf-c-wizard__nav-link.pf-m-disabled')).toHaveLength(
0
);
});

it('have all steps', async () => {
selectors.selectJobCategoriesStatus.mockImplementation(() => null);
selectors.selectJobTemplate.mockRestore();
selectors.selectJobTemplates.mockRestore();
selectors.selectJobCategories.mockRestore();
mockApi(api);
Expand Down
141 changes: 141 additions & 0 deletions webpack/JobWizard/__tests__/validation.test.js
@@ -0,0 +1,141 @@
import React from 'react';
import { Provider } from 'react-redux';
import { render, fireEvent, screen, act } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
import '@testing-library/jest-dom';
import * as api from 'foremanReact/redux/API';
import { JobWizard } from '../JobWizard';
import * as selectors from '../JobWizardSelectors';
import { testSetup, mockApi, jobTemplateResponse, qglMock } from './fixtures';
import { WIZARD_TITLES } from '../JobWizardConstants';

const store = testSetup(selectors, api);

mockApi(api);
const templateInputs = [...jobTemplateResponse.template_inputs];
const advancedTemplateInputs = [
...jobTemplateResponse.advanced_template_inputs,
];
templateInputs[0].default = null;
advancedTemplateInputs[0].default = null;
selectors.selectTemplateInputs.mockImplementation(() => templateInputs);
selectors.selectAdvancedTemplateInputs.mockImplementation(
() => advancedTemplateInputs
);

describe('Job wizard validation', () => {
afterAll(() => {
selectors.selectTemplateInputs.mockRestore();
selectors.selectAdvancedTemplateInputs.mockRestore();
});
it('requeried', async () => {
render(
<MockedProvider mocks={qglMock} addTypename={false}>
<Provider store={store}>
<JobWizard />
</Provider>
</MockedProvider>
);
expect(screen.getByText(WIZARD_TITLES.advanced)).toBeDisabled();
expect(screen.getByText(WIZARD_TITLES.schedule)).toBeDisabled();
expect(screen.getByText(WIZARD_TITLES.review)).toBeDisabled();
await act(async () => {
fireEvent.click(screen.getByText(WIZARD_TITLES.hostsAndInputs));
});
const textField = screen.getByLabelText('plain hidden', {
selector: 'textarea',
});
await act(async () => {
await fireEvent.change(textField, {
target: { value: 'text' },
});
});
expect(screen.getByText(WIZARD_TITLES.advanced)).toBeEnabled();
expect(screen.getByText(WIZARD_TITLES.schedule)).toBeDisabled();
expect(screen.getByText(WIZARD_TITLES.review)).toBeDisabled();

await act(async () => {
fireEvent.click(screen.getByText(WIZARD_TITLES.advanced));
});
const advTextField = screen.getByLabelText('adv plain hidden', {
selector: 'textarea',
});
await act(async () => {
await fireEvent.change(advTextField, {
target: { value: 'text' },
});
});

expect(
screen.getByText(WIZARD_TITLES.advanced, { selector: 'button' })
).toBeEnabled();
expect(screen.getByText(WIZARD_TITLES.schedule)).toBeEnabled();
expect(screen.getByText(WIZARD_TITLES.review)).toBeEnabled();
});

it('advanced number', async () => {
render(
<MockedProvider mocks={qglMock} addTypename={false}>
<Provider store={store}>
<JobWizard />
</Provider>
</MockedProvider>
);

// setup
await act(async () => {
fireEvent.click(screen.getByText(WIZARD_TITLES.hostsAndInputs));
});
await act(async () => {
await fireEvent.change(
screen.getByLabelText('plain hidden', {
selector: 'textarea',
}),
{
target: { value: 'text' },
}
);
});

await act(async () => {
fireEvent.click(screen.getByText(WIZARD_TITLES.advanced));
});
await act(async () => {
await fireEvent.change(
screen.getByLabelText('adv plain hidden', {
selector: 'textarea',
}),
{
target: { value: 'text' },
}
);
});
expect(
screen.getByText(WIZARD_TITLES.advanced, { selector: 'button' })
).toBeEnabled();
expect(screen.getByText(WIZARD_TITLES.schedule)).toBeEnabled();
expect(screen.getByText(WIZARD_TITLES.review)).toBeEnabled();

// test
const timeoutField = screen.getByLabelText('timeout to kill', {
selector: 'input',
});
await act(async () => {
await fireEvent.change(timeoutField, {
target: { value: 'text' },
});
});

expect(screen.getByText(WIZARD_TITLES.schedule)).toBeDisabled();
expect(screen.getByText(WIZARD_TITLES.review)).toBeDisabled();

await act(async () => {
await fireEvent.change(timeoutField, {
target: { value: 123 },
});
});

expect(screen.getByText(WIZARD_TITLES.schedule)).toBeEnabled();
expect(screen.getByText(WIZARD_TITLES.review)).toBeEnabled();
});
});
Expand Up @@ -226,6 +226,11 @@ describe('AdvancedFields', () => {

it('DescriptionField with no inputs', async () => {
jest.spyOn(api, 'get');

jest.spyOn(selectors, 'selectTemplateInputs');
jest.spyOn(selectors, 'selectAdvancedTemplateInputs');
selectors.selectTemplateInputs.mockImplementation(() => []);
selectors.selectAdvancedTemplateInputs.mockImplementation(() => []);
api.get.mockImplementation(({ handleSuccess, ...action }) => {
if (action.key === 'JOB_CATEGORIES') {
handleSuccess &&
Expand Down Expand Up @@ -264,6 +269,20 @@ describe('AdvancedFields', () => {

it('DescriptionField with description_format', async () => {
jest.spyOn(api, 'get');
jest.spyOn(selectors, 'selectTemplateInputs');
selectors.selectTemplateInputs.mockImplementation(() => [
{
name: 'command',
required: true,
input_type: 'user',
description: 'some Description',
advanced: true,
value_type: 'plain',
resource_type: 'ansible_roles',
default: 'Default val',
hidden_value: true,
},
]);
api.get.mockImplementation(({ handleSuccess, ...action }) => {
if (action.key === 'JOB_CATEGORIES') {
handleSuccess &&
Expand Down Expand Up @@ -317,11 +336,11 @@ describe('AdvancedFields', () => {
});

it('search resources action', async () => {
store.clearActions();
jest.useFakeTimers();

mockApi(api);
const newStore = testSetup(selectors, api);
render(
<Provider store={store}>
<Provider store={newStore}>
<JobWizard />
</Provider>
);
Expand All @@ -337,8 +356,8 @@ describe('AdvancedFields', () => {
target: { value: 'some search' },
});

jest.runAllTimers();
await jest.runAllTimers();
});
expect(store.getActions()).toMatchSnapshot('resource search');
expect(newStore.getActions()).toMatchSnapshot('resource search');
});
});
5 changes: 3 additions & 2 deletions webpack/JobWizard/steps/CategoryAndTemplate/index.js
Expand Up @@ -54,10 +54,11 @@ const ConnectedCategoryAndTemplate = ({
search: `job_category="${category}"`,
per_page: 'all',
}),
handleSuccess: response =>
handleSuccess: response => {
setJobTemplate(
Number(filterJobTemplates(response?.data?.results)[0]?.id) || null
),
);
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just curious, did it change something?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure, I think it was just "more correct" for it not to return the value

})
);
}
Expand Down