Merge pull request #9028 from mabashian/7015-prompt-cred-passwords-v2

Add support for credential password prompting on job launch

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2021-01-20 19:32:48 +00:00 committed by GitHub
commit 8a7c714613
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1520 additions and 207 deletions

View File

@ -12,7 +12,15 @@ import {
import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
function PasswordInput(props) {
const { id, name, validate, isRequired, isDisabled, i18n } = props;
const {
autocomplete,
id,
name,
validate,
isRequired,
isDisabled,
i18n,
} = props;
const [inputType, setInputType] = useState('password');
const [field, meta] = useField({ name, validate });
@ -38,6 +46,7 @@ function PasswordInput(props) {
</Button>
</Tooltip>
<TextInput
autoComplete={autocomplete}
id={id}
placeholder={field.value === '$encrypted$' ? 'ENCRYPTED' : undefined}
{...field}
@ -55,6 +64,7 @@ function PasswordInput(props) {
}
PasswordInput.propTypes = {
autocomplete: PropTypes.string,
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
validate: PropTypes.func,
@ -63,6 +73,7 @@ PasswordInput.propTypes = {
};
PasswordInput.defaultProps = {
autocomplete: 'new-password',
validate: () => {},
isRequired: false,
isDisabled: false,

View File

@ -25,6 +25,8 @@ function canLaunchWithoutPrompt(launchData) {
!launchData.ask_limit_on_launch &&
!launchData.ask_scm_branch_on_launch &&
!launchData.survey_enabled &&
(!launchData.passwords_needed_to_start ||
launchData.passwords_needed_to_start.length === 0) &&
(!launchData.variables_needed_to_start ||
launchData.variables_needed_to_start.length === 0)
);
@ -100,17 +102,20 @@ class LaunchButton extends React.Component {
async launchWithParams(params) {
try {
const { history, resource } = this.props;
const jobPromise =
resource.type === 'workflow_job_template'
? WorkflowJobTemplatesAPI.launch(resource.id, params || {})
: JobTemplatesAPI.launch(resource.id, params || {});
let jobPromise;
if (resource.type === 'job_template') {
jobPromise = JobTemplatesAPI.launch(resource.id, params || {});
} else if (resource.type === 'workflow_job_template') {
jobPromise = WorkflowJobTemplatesAPI.launch(resource.id, params || {});
} else if (resource.type === 'job') {
jobPromise = JobsAPI.relaunch(resource.id, params || {});
} else if (resource.type === 'workflow_job') {
jobPromise = WorkflowJobsAPI.relaunch(resource.id, params || {});
}
const { data: job } = await jobPromise;
history.push(
`/${
resource.type === 'workflow_job_template' ? 'jobs/workflow' : 'jobs'
}/${job.id}/output`
);
history.push(`/jobs/${job.id}/output`);
} catch (launchError) {
this.setState({ launchError });
}
@ -127,20 +132,15 @@ class LaunchButton extends React.Component {
readRelaunch = InventorySourcesAPI.readLaunchUpdate(
resource.inventory_source
);
relaunch = InventorySourcesAPI.launchUpdate(resource.inventory_source);
} else if (resource.type === 'project_update') {
// We'll need to handle the scenario where the project no longer exists
readRelaunch = ProjectsAPI.readLaunchUpdate(resource.project);
relaunch = ProjectsAPI.launchUpdate(resource.project);
} else if (resource.type === 'workflow_job') {
readRelaunch = WorkflowJobsAPI.readRelaunch(resource.id);
relaunch = WorkflowJobsAPI.relaunch(resource.id);
} else if (resource.type === 'ad_hoc_command') {
readRelaunch = AdHocCommandsAPI.readRelaunch(resource.id);
relaunch = AdHocCommandsAPI.relaunch(resource.id);
} else if (resource.type === 'job') {
readRelaunch = JobsAPI.readRelaunch(resource.id);
relaunch = JobsAPI.relaunch(resource.id);
}
try {
@ -149,11 +149,22 @@ class LaunchButton extends React.Component {
!relaunchConfig.passwords_needed_to_start ||
relaunchConfig.passwords_needed_to_start.length === 0
) {
if (resource.type === 'inventory_update') {
relaunch = InventorySourcesAPI.launchUpdate(
resource.inventory_source
);
} else if (resource.type === 'project_update') {
relaunch = ProjectsAPI.launchUpdate(resource.project);
} else if (resource.type === 'workflow_job') {
relaunch = WorkflowJobsAPI.relaunch(resource.id);
} else if (resource.type === 'ad_hoc_command') {
relaunch = AdHocCommandsAPI.relaunch(resource.id);
} else if (resource.type === 'job') {
relaunch = JobsAPI.relaunch(resource.id);
}
const { data: job } = await relaunch;
history.push(`/jobs/${job.id}/output`);
} else {
// TODO: restructure (async?) to send launch command after prompts
// TODO: does relaunch need different prompt treatment than launch?
this.setState({
showLaunchPrompt: true,
launchConfig: relaunchConfig,

View File

@ -4,10 +4,16 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import { sleep } from '../../../testUtils/testUtils';
import LaunchButton from './LaunchButton';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../api';
import {
InventorySourcesAPI,
JobsAPI,
JobTemplatesAPI,
ProjectsAPI,
WorkflowJobsAPI,
WorkflowJobTemplatesAPI,
} from '../../api';
jest.mock('../../api/models/WorkflowJobTemplates');
jest.mock('../../api/models/JobTemplates');
jest.mock('../../api');
describe('LaunchButton', () => {
JobTemplatesAPI.readLaunch.mockResolvedValue({
@ -22,10 +28,14 @@ describe('LaunchButton', () => {
},
});
const children = ({ handleLaunch }) => (
const launchButton = ({ handleLaunch }) => (
<button type="submit" onClick={() => handleLaunch()} />
);
const relaunchButton = ({ handleRelaunch }) => (
<button type="submit" onClick={() => handleRelaunch()} />
);
const resource = {
id: 1,
type: 'job_template',
@ -35,7 +45,7 @@ describe('LaunchButton', () => {
test('renders the expected content', () => {
const wrapper = mountWithContexts(
<LaunchButton resource={resource}>{children}</LaunchButton>
<LaunchButton resource={resource}>{launchButton}</LaunchButton>
);
expect(wrapper).toHaveLength(1);
});
@ -51,7 +61,7 @@ describe('LaunchButton', () => {
},
});
const wrapper = mountWithContexts(
<LaunchButton resource={resource}>{children}</LaunchButton>,
<LaunchButton resource={resource}>{launchButton}</LaunchButton>,
{
context: {
router: { history },
@ -87,7 +97,7 @@ describe('LaunchButton', () => {
type: 'workflow_job_template',
}}
>
{children}
{launchButton}
</LaunchButton>,
{
context: {
@ -100,12 +110,162 @@ describe('LaunchButton', () => {
expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
await sleep(0);
expect(WorkflowJobTemplatesAPI.launch).toHaveBeenCalledWith(1, {});
expect(history.location.pathname).toEqual('/jobs/workflow/9000/output');
expect(history.location.pathname).toEqual('/jobs/9000/output');
});
test('should relaunch job correctly', async () => {
JobsAPI.readRelaunch.mockResolvedValue({
data: {
can_start_without_user_input: true,
},
});
const history = createMemoryHistory({
initialEntries: ['/jobs/9000'],
});
JobsAPI.relaunch.mockResolvedValue({
data: {
id: 9000,
},
});
const wrapper = mountWithContexts(
<LaunchButton
resource={{
id: 1,
type: 'job',
}}
>
{relaunchButton}
</LaunchButton>,
{
context: {
router: { history },
},
}
);
const button = wrapper.find('button');
button.prop('onClick')();
expect(JobsAPI.readRelaunch).toHaveBeenCalledWith(1);
await sleep(0);
expect(JobsAPI.relaunch).toHaveBeenCalledWith(1);
expect(history.location.pathname).toEqual('/jobs/9000/output');
});
test('should relaunch workflow job correctly', async () => {
WorkflowJobsAPI.readRelaunch.mockResolvedValue({
data: {
can_start_without_user_input: true,
},
});
const history = createMemoryHistory({
initialEntries: ['/jobs/9000'],
});
WorkflowJobsAPI.relaunch.mockResolvedValue({
data: {
id: 9000,
},
});
const wrapper = mountWithContexts(
<LaunchButton
resource={{
id: 1,
type: 'workflow_job',
}}
>
{relaunchButton}
</LaunchButton>,
{
context: {
router: { history },
},
}
);
const button = wrapper.find('button');
button.prop('onClick')();
expect(WorkflowJobsAPI.readRelaunch).toHaveBeenCalledWith(1);
await sleep(0);
expect(WorkflowJobsAPI.relaunch).toHaveBeenCalledWith(1);
expect(history.location.pathname).toEqual('/jobs/9000/output');
});
test('should relaunch project sync correctly', async () => {
ProjectsAPI.readLaunchUpdate.mockResolvedValue({
data: {
can_start_without_user_input: true,
},
});
const history = createMemoryHistory({
initialEntries: ['/jobs/9000'],
});
ProjectsAPI.launchUpdate.mockResolvedValue({
data: {
id: 9000,
},
});
const wrapper = mountWithContexts(
<LaunchButton
resource={{
id: 1,
project: 5,
type: 'project_update',
}}
>
{relaunchButton}
</LaunchButton>,
{
context: {
router: { history },
},
}
);
const button = wrapper.find('button');
button.prop('onClick')();
expect(ProjectsAPI.readLaunchUpdate).toHaveBeenCalledWith(5);
await sleep(0);
expect(ProjectsAPI.launchUpdate).toHaveBeenCalledWith(5);
expect(history.location.pathname).toEqual('/jobs/9000/output');
});
test('should relaunch project sync correctly', async () => {
InventorySourcesAPI.readLaunchUpdate.mockResolvedValue({
data: {
can_start_without_user_input: true,
},
});
const history = createMemoryHistory({
initialEntries: ['/jobs/9000'],
});
InventorySourcesAPI.launchUpdate.mockResolvedValue({
data: {
id: 9000,
},
});
const wrapper = mountWithContexts(
<LaunchButton
resource={{
id: 1,
inventory_source: 5,
type: 'inventory_update',
}}
>
{relaunchButton}
</LaunchButton>,
{
context: {
router: { history },
},
}
);
const button = wrapper.find('button');
button.prop('onClick')();
expect(InventorySourcesAPI.readLaunchUpdate).toHaveBeenCalledWith(5);
await sleep(0);
expect(InventorySourcesAPI.launchUpdate).toHaveBeenCalledWith(5);
expect(history.location.pathname).toEqual('/jobs/9000/output');
});
test('displays error modal after unsuccessful launch', async () => {
const wrapper = mountWithContexts(
<LaunchButton resource={resource}>{children}</LaunchButton>
<LaunchButton resource={resource}>{launchButton}</LaunchButton>
);
JobTemplatesAPI.launch.mockRejectedValue(
new Error({

View File

@ -19,17 +19,18 @@ function PromptModalForm({
resource,
surveyConfig,
}) {
const { values, setTouched, validateForm } = useFormikContext();
const { setFieldTouched, values } = useFormikContext();
const {
steps,
isReady,
validateStep,
visitStep,
visitAllSteps,
contentError,
} = useLaunchSteps(launchConfig, surveyConfig, resource, i18n);
const handleSave = () => {
const handleSubmit = () => {
const postValues = {};
const setValue = (key, value) => {
if (typeof value !== 'undefined' && value !== null) {
@ -37,6 +38,7 @@ function PromptModalForm({
}
};
const surveyValues = getSurveyValues(values);
setValue('credential_passwords', values.credential_passwords);
setValue('inventory_id', values.inventory?.id);
setValue(
'credentials',
@ -75,22 +77,25 @@ function PromptModalForm({
<Wizard
isOpen
onClose={onCancel}
onSave={handleSave}
onSave={handleSubmit}
onBack={async nextStep => {
validateStep(nextStep.id);
}}
onNext={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') {
visitAllSteps(setTouched);
visitAllSteps(setFieldTouched);
} else {
visitStep(prevStep.prevId);
visitStep(prevStep.prevId, setFieldTouched);
validateStep(nextStep.id);
}
await validateForm();
}}
onGoToStep={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') {
visitAllSteps(setTouched);
visitAllSteps(setFieldTouched);
} else {
visitStep(prevStep.prevId);
visitStep(prevStep.prevId, setFieldTouched);
validateStep(nextStep.id);
}
await validateForm();
}}
title={i18n._(t`Prompts`)}
steps={

View File

@ -82,8 +82,26 @@ describe('LaunchPrompt', () => {
ask_credential_on_launch: true,
ask_scm_branch_on_launch: true,
survey_enabled: true,
passwords_needed_to_start: ['ssh_password'],
defaults: {
credentials: [
{
id: 1,
passwords_needed: ['ssh_password'],
},
],
},
}}
resource={{
...resource,
summary_fields: {
credentials: [
{
id: 1,
},
],
},
}}
resource={resource}
onLaunch={noop}
onCancel={noop}
surveyConfig={{
@ -110,12 +128,13 @@ describe('LaunchPrompt', () => {
const wizard = await waitForElement(wrapper, 'Wizard');
const steps = wizard.prop('steps');
expect(steps).toHaveLength(5);
expect(steps).toHaveLength(6);
expect(steps[0].name.props.children).toEqual('Inventory');
expect(steps[1].name.props.children).toEqual('Credentials');
expect(steps[2].name.props.children).toEqual('Other prompts');
expect(steps[3].name.props.children).toEqual('Survey');
expect(steps[4].name.props.children).toEqual('Preview');
expect(steps[2].name.props.children).toEqual('Credential passwords');
expect(steps[3].name.props.children).toEqual('Other prompts');
expect(steps[4].name.props.children).toEqual('Survey');
expect(steps[5].name.props.children).toEqual('Preview');
});
test('should add inventory step', async () => {

View File

@ -0,0 +1,131 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Form } from '@patternfly/react-core';
import { useFormikContext } from 'formik';
import { PasswordField } from '../../FormField';
function CredentialPasswordsStep({ launchConfig, i18n }) {
const {
values: { credentials },
} = useFormikContext();
const vaultsThatPrompt = [];
let showcredentialPasswordSsh = false;
let showcredentialPasswordPrivilegeEscalation = false;
let showcredentialPasswordPrivateKeyPassphrase = false;
if (
!launchConfig.ask_credential_on_launch &&
launchConfig.passwords_needed_to_start
) {
launchConfig.passwords_needed_to_start.forEach(password => {
if (password === 'ssh_password') {
showcredentialPasswordSsh = true;
} else if (password === 'become_password') {
showcredentialPasswordPrivilegeEscalation = true;
} else if (password === 'ssh_key_unlock') {
showcredentialPasswordPrivateKeyPassphrase = true;
} else if (password.startsWith('vault_password')) {
const vaultId = password.split(/\.(.+)/)[1] || '';
vaultsThatPrompt.push(vaultId);
}
});
} else if (credentials) {
credentials.forEach(credential => {
if (!credential.inputs) {
const launchConfigCredential = launchConfig.defaults.credentials.find(
defaultCred => defaultCred.id === credential.id
);
if (launchConfigCredential?.passwords_needed.length > 0) {
if (
launchConfigCredential.passwords_needed.includes('ssh_password')
) {
showcredentialPasswordSsh = true;
}
if (
launchConfigCredential.passwords_needed.includes('become_password')
) {
showcredentialPasswordPrivilegeEscalation = true;
}
if (
launchConfigCredential.passwords_needed.includes('ssh_key_unlock')
) {
showcredentialPasswordPrivateKeyPassphrase = true;
}
const vaultPasswordIds = launchConfigCredential.passwords_needed
.filter(passwordNeeded =>
passwordNeeded.startsWith('vault_password')
)
.map(vaultPassword => vaultPassword.split(/\.(.+)/)[1] || '');
vaultsThatPrompt.push(...vaultPasswordIds);
}
} else {
if (credential?.inputs?.password === 'ASK') {
showcredentialPasswordSsh = true;
}
if (credential?.inputs?.become_password === 'ASK') {
showcredentialPasswordPrivilegeEscalation = true;
}
if (credential?.inputs?.ssh_key_unlock === 'ASK') {
showcredentialPasswordPrivateKeyPassphrase = true;
}
if (credential?.inputs?.vault_password === 'ASK') {
vaultsThatPrompt.push(credential.inputs.vault_id);
}
}
});
}
return (
<Form>
{showcredentialPasswordSsh && (
<PasswordField
id="launch-ssh-password"
label={i18n._(t`SSH password`)}
name="credential_passwords.ssh_password"
isRequired
/>
)}
{showcredentialPasswordPrivateKeyPassphrase && (
<PasswordField
id="launch-private-key-passphrase"
label={i18n._(t`Private key passphrase`)}
name="credential_passwords.ssh_key_unlock"
isRequired
/>
)}
{showcredentialPasswordPrivilegeEscalation && (
<PasswordField
id="launch-privilege-escalation-password"
label={i18n._(t`Privilege escalation password`)}
name="credential_passwords.become_password"
isRequired
/>
)}
{vaultsThatPrompt.map(credId => (
<PasswordField
id={`launch-vault-password-${credId}`}
key={credId}
label={
credId === ''
? i18n._(t`Vault password`)
: i18n._(t`Vault password | ${credId}`)
}
name={`credential_passwords['vault_password${
credId !== '' ? `.${credId}` : ''
}']`}
isRequired
/>
))}
</Form>
);
}
export default withI18n()(CredentialPasswordsStep);

View File

@ -0,0 +1,603 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import CredentialPasswordsStep from './CredentialPasswordsStep';
describe('CredentialPasswordsStep', () => {
describe('JT default credentials (no credential replacement) and creds are promptable', () => {
test('should render ssh password field when JT has default machine cred', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [
{
id: 1,
passwords_needed: ['ssh_password'],
},
],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render become password field when JT has default machine cred', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [
{
id: 1,
passwords_needed: ['become_password'],
},
],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(1);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render private key passphrase field when JT has default machine cred', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
defaults: {
ask_credential_on_launch: true,
credentials: [
{
id: 1,
passwords_needed: ['ssh_key_unlock'],
},
],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render vault password field when JT has default vault cred', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [
{
id: 1,
passwords_needed: ['vault_password.1'],
},
],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-vault-password-1')
).toHaveLength(1);
});
test('should render all password field when JT has default vault cred and machine cred', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
},
{
id: 2,
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [
{
id: 1,
passwords_needed: [
'ssh_password',
'become_password',
'ssh_key_unlock',
],
},
{
id: 2,
passwords_needed: ['vault_password.1'],
},
],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(1);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-vault-password-1')
).toHaveLength(1);
});
});
describe('Credentials have been replaced and creds are promptable', () => {
test('should render ssh password field when replacement machine cred prompts for it', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
inputs: {
password: 'ASK',
become_password: null,
ssh_key_unlock: null,
vault_password: null,
},
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render become password field when replacement machine cred prompts for it', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
inputs: {
password: null,
become_password: 'ASK',
ssh_key_unlock: null,
vault_password: null,
},
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(1);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render private key passphrase field when replacement machine cred prompts for it', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
inputs: {
password: null,
become_password: null,
ssh_key_unlock: 'ASK',
vault_password: null,
},
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render vault password field when replacement vault cred prompts for it', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
inputs: {
password: null,
become_password: null,
ssh_key_unlock: null,
vault_password: 'ASK',
vault_id: 'foobar',
},
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-vault-password-foobar')
).toHaveLength(1);
});
test('should render all password fields when replacement vault and machine creds prompt for it', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
inputs: {
password: 'ASK',
become_password: 'ASK',
ssh_key_unlock: 'ASK',
},
},
{
id: 2,
inputs: {
password: null,
become_password: null,
ssh_key_unlock: null,
vault_password: 'ASK',
vault_id: 'foobar',
},
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(1);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-vault-password-foobar')
).toHaveLength(1);
});
});
describe('Credentials have been replaced and creds are not promptable', () => {
test('should render ssh password field when required', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{}}>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: false,
passwords_needed_to_start: ['ssh_password'],
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render become password field when required', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{}}>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: false,
passwords_needed_to_start: ['become_password'],
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(1);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render private key passphrase field when required', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{}}>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: false,
passwords_needed_to_start: ['ssh_key_unlock'],
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render vault password field when required', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{}}>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: false,
passwords_needed_to_start: ['vault_password.foobar'],
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-vault-password-foobar')
).toHaveLength(1);
});
test('should render all password fields when required', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{}}>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: false,
passwords_needed_to_start: [
'ssh_password',
'become_password',
'ssh_key_unlock',
'vault_password.foobar',
],
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(1);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-vault-password-foobar')
).toHaveLength(1);
});
});
});

View File

@ -3,6 +3,7 @@ import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import { Alert } from '@patternfly/react-core';
import { InventoriesAPI } from '../../../api';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import useRequest from '../../../util/useRequest';
@ -17,9 +18,10 @@ const QS_CONFIG = getQSConfig('inventory', {
});
function InventoryStep({ i18n }) {
const [field, , helpers] = useField({
const [field, meta, helpers] = useField({
name: 'inventory',
});
const history = useHistory();
const {
@ -65,40 +67,45 @@ function InventoryStep({ i18n }) {
}
return (
<OptionsList
value={field.value ? [field.value] : []}
options={inventories}
optionCount={count}
searchColumns={[
{
name: i18n._(t`Name`),
key: 'name__icontains',
isDefault: true,
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username__icontains',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username__icontains',
},
]}
sortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
header={i18n._(t`Inventory`)}
name="inventory"
qsConfig={QS_CONFIG}
readOnly
selectItem={helpers.setValue}
deselectItem={() => field.onChange(null)}
/>
<>
<OptionsList
value={field.value ? [field.value] : []}
options={inventories}
optionCount={count}
searchColumns={[
{
name: i18n._(t`Name`),
key: 'name__icontains',
isDefault: true,
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username__icontains',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username__icontains',
},
]}
sortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
header={i18n._(t`Inventory`)}
name="inventory"
qsConfig={QS_CONFIG}
readOnly
selectItem={helpers.setValue}
deselectItem={() => field.onChange(null)}
/>
{meta.touched && meta.error && (
<Alert variant="danger" isInline title={meta.error} />
)}
</>
);
}

View File

@ -0,0 +1,254 @@
import React from 'react';
import { t } from '@lingui/macro';
import { useFormikContext } from 'formik';
import CredentialPasswordsStep from './CredentialPasswordsStep';
import StepName from './StepName';
const STEP_ID = 'credentialPasswords';
const isValueMissing = val => {
return !val || val === '';
};
export default function useCredentialPasswordsStep(
launchConfig,
i18n,
showStep,
visitedSteps
) {
const { values, setFieldError } = useFormikContext();
const hasError =
Object.keys(visitedSteps).includes(STEP_ID) &&
checkForError(launchConfig, values);
return {
step: showStep
? {
id: STEP_ID,
name: (
<StepName hasErrors={hasError} id="credential-passwords-step">
{i18n._(t`Credential passwords`)}
</StepName>
),
component: (
<CredentialPasswordsStep launchConfig={launchConfig} i18n={i18n} />
),
enableNext: true,
}
: null,
initialValues: getInitialValues(launchConfig, values.credentials),
isReady: true,
contentError: null,
hasError,
setTouched: setFieldTouched => {
Object.keys(values.credential_passwords).forEach(credentialValueKey =>
setFieldTouched(
`credential_passwords['${credentialValueKey}']`,
true,
false
)
);
},
validate: () => {
const setPasswordFieldError = fieldName => {
setFieldError(fieldName, i18n._(t`This field may not be blank`));
};
if (
!launchConfig.ask_credential_on_launch &&
launchConfig.passwords_needed_to_start
) {
launchConfig.passwords_needed_to_start.forEach(password => {
if (isValueMissing(values.credential_passwords[password])) {
setPasswordFieldError(`credential_passwords['${password}']`);
}
});
} else if (values.credentials) {
values.credentials.forEach(credential => {
if (!credential.inputs) {
const launchConfigCredential = launchConfig.defaults.credentials.find(
defaultCred => defaultCred.id === credential.id
);
if (launchConfigCredential?.passwords_needed.length > 0) {
launchConfigCredential.passwords_needed.forEach(password => {
if (isValueMissing(values.credential_passwords[password])) {
setPasswordFieldError(`credential_passwords['${password}']`);
}
});
}
} else {
if (
credential?.inputs?.password === 'ASK' &&
isValueMissing(values.credential_passwords.ssh_password)
) {
setPasswordFieldError('credential_passwords.ssh_password');
}
if (
credential?.inputs?.become_password === 'ASK' &&
isValueMissing(values.credential_passwords.become_password)
) {
setPasswordFieldError('credential_passwords.become_password');
}
if (
credential?.inputs?.ssh_key_unlock === 'ASK' &&
isValueMissing(values.credential_passwords.ssh_key_unlock)
) {
setPasswordFieldError('credential_passwords.ssh_key_unlock');
}
if (
credential?.inputs?.vault_password === 'ASK' &&
isValueMissing(
values.credential_passwords[
`vault_password${
credential.inputs.vault_id !== ''
? `.${credential.inputs.vault_id}`
: ''
}`
]
)
) {
setPasswordFieldError(
`credential_passwords['vault_password${
credential.inputs.vault_id !== ''
? `.${credential.inputs.vault_id}`
: ''
}']`
);
}
}
});
}
},
};
}
function getInitialValues(launchConfig, selectedCredentials = []) {
const initialValues = {
credential_passwords: {},
};
if (!launchConfig) {
return initialValues;
}
if (
!launchConfig.ask_credential_on_launch &&
launchConfig.passwords_needed_to_start
) {
launchConfig.passwords_needed_to_start.forEach(password => {
initialValues.credential_passwords[password] = '';
});
return initialValues;
}
selectedCredentials.forEach(credential => {
if (!credential.inputs) {
const launchConfigCredential = launchConfig.defaults.credentials.find(
defaultCred => defaultCred.id === credential.id
);
if (launchConfigCredential?.passwords_needed.length > 0) {
launchConfigCredential.passwords_needed.forEach(password => {
initialValues.credential_passwords[password] = '';
});
}
} else {
if (credential?.inputs?.password === 'ASK') {
initialValues.credential_passwords.ssh_password = '';
}
if (credential?.inputs?.become_password === 'ASK') {
initialValues.credential_passwords.become_password = '';
}
if (credential?.inputs?.ssh_key_unlock === 'ASK') {
initialValues.credential_passwords.ssh_key_unlock = '';
}
if (credential?.inputs?.vault_password === 'ASK') {
if (!credential.inputs.vault_id || credential.inputs.vault_id === '') {
initialValues.credential_passwords.vault_password = '';
} else {
initialValues.credential_passwords[
`vault_password.${credential.inputs.vault_id}`
] = '';
}
}
}
});
return initialValues;
}
function checkForError(launchConfig, values) {
let hasError = false;
if (
!launchConfig.ask_credential_on_launch &&
launchConfig.passwords_needed_to_start
) {
launchConfig.passwords_needed_to_start.forEach(password => {
if (isValueMissing(values.credential_passwords[password])) {
hasError = true;
}
});
} else if (values.credentials) {
values.credentials.forEach(credential => {
if (!credential.inputs) {
const launchConfigCredential = launchConfig.defaults.credentials.find(
defaultCred => defaultCred.id === credential.id
);
if (launchConfigCredential?.passwords_needed.length > 0) {
launchConfigCredential.passwords_needed.forEach(password => {
if (isValueMissing(values.credential_passwords[password])) {
hasError = true;
}
});
}
} else {
if (
credential?.inputs?.password === 'ASK' &&
isValueMissing(values.credential_passwords.ssh_password)
) {
hasError = true;
}
if (
credential?.inputs?.become_password === 'ASK' &&
isValueMissing(values.credential_passwords.become_password)
) {
hasError = true;
}
if (
credential?.inputs?.ssh_key_unlock === 'ASK' &&
isValueMissing(values.credential_passwords.ssh_key_unlock)
) {
hasError = true;
}
if (
credential?.inputs?.vault_password === 'ASK' &&
isValueMissing(
values.credential_passwords[
`vault_password${
credential.inputs.vault_id !== ''
? `.${credential.inputs.vault_id}`
: ''
}`
]
)
) {
hasError = true;
}
}
});
}
return hasError;
}

View File

@ -9,15 +9,13 @@ export default function useCredentialsStep(launchConfig, resource, i18n) {
return {
step: getStep(launchConfig, i18n),
initialValues: getInitialValues(launchConfig, resource),
validate: () => ({}),
isReady: true,
contentError: null,
formError: null,
setTouched: setFieldsTouched => {
setFieldsTouched({
credentials: true,
});
hasError: false,
setTouched: setFieldTouched => {
setFieldTouched('credentials', true, false);
},
validate: () => {},
};
}

View File

@ -12,20 +12,27 @@ export default function useInventoryStep(
i18n,
visitedSteps
) {
const [, meta] = useField('inventory');
const [, meta, helpers] = useField('inventory');
const formError =
Object.keys(visitedSteps).includes(STEP_ID) && (!meta.value || meta.error);
!resource || resource?.type === 'workflow_job_template'
? false
: Object.keys(visitedSteps).includes(STEP_ID) &&
meta.touched &&
!meta.value;
return {
step: getStep(launchConfig, i18n, formError),
initialValues: getInitialValues(launchConfig, resource),
isReady: true,
contentError: null,
formError: launchConfig.ask_inventory_on_launch && formError,
setTouched: setFieldsTouched => {
setFieldsTouched({
inventory: true,
});
hasError: launchConfig.ask_inventory_on_launch && formError,
setTouched: setFieldTouched => {
setFieldTouched('inventory', true, false);
},
validate: () => {
if (meta.touched && !meta.value && resource.type === 'job_template') {
helpers.setError(i18n._(t`An inventory must be selected`));
}
},
};
}

View File

@ -22,18 +22,19 @@ export default function useOtherPromptsStep(launchConfig, resource, i18n) {
initialValues: getInitialValues(launchConfig, resource),
isReady: true,
contentError: null,
formError: null,
setTouched: setFieldsTouched => {
setFieldsTouched({
job_type: true,
limit: true,
verbosity: true,
diff_mode: true,
job_tags: true,
skip_tags: true,
extra_vars: true,
});
hasError: false,
setTouched: setFieldTouched => {
[
'job_type',
'limit',
'verbosity',
'diff_mode',
'job_tags',
'skip_tags',
'extra_vars',
].forEach(field => setFieldTouched(field, true, false));
},
validate: () => {},
};
}

View File

@ -35,9 +35,9 @@ export default function usePreviewStep(
}
: null,
initialValues: {},
validate: () => ({}),
isReady: true,
error: null,
setTouched: () => {},
validate: () => {},
};
}

View File

@ -13,89 +13,51 @@ export default function useSurveyStep(
i18n,
visitedSteps
) {
const { values } = useFormikContext();
const errors = {};
const validate = () => {
if (!launchConfig.survey_enabled || !surveyConfig?.spec) {
return {};
}
surveyConfig.spec.forEach(question => {
const errMessage = validateField(
question,
values[`survey_${question.variable}`],
i18n
);
if (errMessage) {
errors[`survey_${question.variable}`] = errMessage;
}
});
return errors;
};
const formError = Object.keys(validate()).length > 0;
const { setFieldError, values } = useFormikContext();
const hasError =
Object.keys(visitedSteps).includes(STEP_ID) &&
checkForError(launchConfig, surveyConfig, values);
return {
step: getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps),
step: launchConfig.survey_enabled
? {
id: STEP_ID,
name: (
<StepName hasErrors={hasError} id="survey-step">
{i18n._(t`Survey`)}
</StepName>
),
component: <SurveyStep surveyConfig={surveyConfig} i18n={i18n} />,
enableNext: true,
}
: null,
initialValues: getInitialValues(launchConfig, surveyConfig, resource),
validate,
surveyConfig,
isReady: true,
contentError: null,
formError,
setTouched: setFieldsTouched => {
hasError,
setTouched: setFieldTouched => {
if (!surveyConfig?.spec) {
return;
}
const fields = {};
surveyConfig.spec.forEach(question => {
fields[`survey_${question.variable}`] = true;
setFieldTouched(`survey_${question.variable}`, true, false);
});
setFieldsTouched(fields);
},
};
}
function validateField(question, value, i18n) {
const isTextField = ['text', 'textarea'].includes(question.type);
const isNumeric = ['integer', 'float'].includes(question.type);
if (isTextField && (value || value === 0)) {
if (question.min && value.length < question.min) {
return i18n._(t`This field must be at least ${question.min} characters`);
}
if (question.max && value.length > question.max) {
return i18n._(t`This field must not exceed ${question.max} characters`);
}
}
if (isNumeric && (value || value === 0)) {
if (value < question.min || value > question.max) {
return i18n._(
t`This field must be a number and have a value between ${question.min} and ${question.max}`
);
}
}
if (question.required && !value && value !== 0) {
return i18n._(t`This field must not be blank`);
}
return null;
}
function getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps) {
if (!launchConfig.survey_enabled) {
return null;
}
return {
id: STEP_ID,
name: (
<StepName
hasErrors={
Object.keys(visitedSteps).includes(STEP_ID) &&
Object.keys(validate()).length
}
id="survey-step"
>
{i18n._(t`Survey`)}
</StepName>
),
component: <SurveyStep surveyConfig={surveyConfig} i18n={i18n} />,
enableNext: true,
validate: () => {
if (launchConfig.survey_enabled && surveyConfig.spec) {
surveyConfig.spec.forEach(question => {
const errMessage = validateSurveyField(
question,
values[`survey_${question.variable}`],
i18n
);
if (errMessage) {
setFieldError(`survey_${question.variable}`, errMessage);
}
});
}
},
};
}
@ -133,3 +95,56 @@ function getInitialValues(launchConfig, surveyConfig, resource) {
return values;
}
function validateSurveyField(question, value, i18n) {
const isTextField = ['text', 'textarea'].includes(question.type);
const isNumeric = ['integer', 'float'].includes(question.type);
if (isTextField && (value || value === 0)) {
if (question.min && value.length < question.min) {
return i18n._(t`This field must be at least ${question.min} characters`);
}
if (question.max && value.length > question.max) {
return i18n._(t`This field must not exceed ${question.max} characters`);
}
}
if (isNumeric && (value || value === 0)) {
if (value < question.min || value > question.max) {
return i18n._(
t`This field must be a number and have a value between ${question.min} and ${question.max}`
);
}
}
if (question.required && !value && value !== 0) {
return i18n._(t`This field must not be blank`);
}
return null;
}
function checkForError(launchConfig, surveyConfig, values) {
let hasError = false;
if (launchConfig.survey_enabled && surveyConfig.spec) {
surveyConfig.spec.forEach(question => {
const value = values[`survey_${question.variable}`];
const isTextField = ['text', 'textarea'].includes(question.type);
const isNumeric = ['integer', 'float'].includes(question.type);
if (isTextField && (value || value === 0)) {
if (
(question.min && value.length < question.min) ||
(question.max && value.length > question.max)
) {
hasError = true;
}
}
if (isNumeric && (value || value === 0)) {
if (value < question.min || value > question.max) {
hasError = true;
}
}
if (question.required && !value && value !== 0) {
hasError = true;
}
});
}
return hasError;
}

View File

@ -2,10 +2,43 @@ import { useState, useEffect } from 'react';
import { useFormikContext } from 'formik';
import useInventoryStep from './steps/useInventoryStep';
import useCredentialsStep from './steps/useCredentialsStep';
import useCredentialPasswordsStep from './steps/useCredentialPasswordsStep';
import useOtherPromptsStep from './steps/useOtherPromptsStep';
import useSurveyStep from './steps/useSurveyStep';
import usePreviewStep from './steps/usePreviewStep';
function showCredentialPasswordsStep(credentials = [], launchConfig) {
if (
!launchConfig?.ask_credential_on_launch &&
launchConfig?.passwords_needed_to_start
) {
return launchConfig.passwords_needed_to_start.length > 0;
}
let credentialPasswordStepRequired = false;
credentials.forEach(credential => {
if (!credential.inputs) {
const launchConfigCredential = launchConfig.defaults.credentials.find(
defaultCred => defaultCred.id === credential.id
);
if (launchConfigCredential?.passwords_needed.length > 0) {
credentialPasswordStepRequired = true;
}
} else if (
credential?.inputs?.password === 'ASK' ||
credential?.inputs?.become_password === 'ASK' ||
credential?.inputs?.ssh_key_unlock === 'ASK' ||
credential?.inputs?.vault_password === 'ASK'
) {
credentialPasswordStepRequired = true;
}
});
return credentialPasswordStepRequired;
}
export default function useLaunchSteps(
launchConfig,
surveyConfig,
@ -14,14 +47,21 @@ export default function useLaunchSteps(
) {
const [visited, setVisited] = useState({});
const [isReady, setIsReady] = useState(false);
const { touched, values: formikValues } = useFormikContext();
const steps = [
useInventoryStep(launchConfig, resource, i18n, visited),
useCredentialsStep(launchConfig, resource, i18n),
useCredentialPasswordsStep(
launchConfig,
i18n,
showCredentialPasswordsStep(formikValues.credentials, launchConfig),
visited
),
useOtherPromptsStep(launchConfig, resource, i18n),
useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited),
];
const { resetForm } = useFormikContext();
const hasErrors = steps.some(step => step.formError);
const hasErrors = steps.some(step => step.hasError);
steps.push(
usePreviewStep(launchConfig, i18n, resource, surveyConfig, hasErrors, true)
@ -38,16 +78,47 @@ export default function useLaunchSteps(
...cur.initialValues,
};
}, {});
const newFormValues = { ...initialValues };
Object.keys(formikValues).forEach(formikValueKey => {
if (
formikValueKey === 'credential_passwords' &&
Object.prototype.hasOwnProperty.call(
newFormValues,
'credential_passwords'
)
) {
const formikCredentialPasswords = formikValues.credential_passwords;
Object.keys(formikCredentialPasswords).forEach(
credentialPasswordValueKey => {
if (
Object.prototype.hasOwnProperty.call(
newFormValues.credential_passwords,
credentialPasswordValueKey
)
) {
newFormValues.credential_passwords[credentialPasswordValueKey] =
formikCredentialPasswords[credentialPasswordValueKey];
}
}
);
} else if (
Object.prototype.hasOwnProperty.call(newFormValues, formikValueKey)
) {
newFormValues[formikValueKey] = formikValues[formikValueKey];
}
});
resetForm({
values: {
...initialValues,
},
values: newFormValues,
touched,
});
setIsReady(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stepsAreReady]);
}, [formikValues.credentials, stepsAreReady]);
const stepWithError = steps.find(s => s.contentError);
const contentError = stepWithError ? stepWithError.contentError : null;
@ -55,20 +126,26 @@ export default function useLaunchSteps(
return {
steps: pfSteps,
isReady,
visitStep: stepId =>
validateStep: stepId => {
steps.find(s => s?.step?.id === stepId).validate();
},
visitStep: (prevStepId, setFieldTouched) => {
setVisited({
...visited,
[stepId]: true,
}),
visitAllSteps: setFieldsTouched => {
[prevStepId]: true,
});
steps.find(s => s?.step?.id === prevStepId).setTouched(setFieldTouched);
},
visitAllSteps: setFieldTouched => {
setVisited({
inventory: true,
credentials: true,
credentialPasswords: true,
other: true,
survey: true,
preview: true,
});
steps.forEach(s => s.setTouched(setFieldsTouched));
steps.forEach(s => s.setTouched(setFieldTouched));
},
contentError,
};

View File

@ -6,6 +6,7 @@ import { t } from '@lingui/macro';
import useRequest from '../../util/useRequest';
import { UnifiedJobsAPI } from '../../api';
import ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading';
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
const NOT_FOUND = 'not found';
@ -46,8 +47,13 @@ function JobTypeRedirect({ id, path, view, i18n }) {
);
}
if (isLoading || !job?.id) {
// TODO show loading state
return <div>Loading...</div>;
return (
<PageSection>
<Card>
<ContentLoading />
</Card>
</PageSection>
);
}
const type = JOB_TYPE_URL_SEGMENTS[job.type];
return <Redirect from={path} to={`/jobs/${type}/${job.id}/${view}`} />;

View File

@ -44,7 +44,7 @@ function NodeModalForm({
}) {
const history = useHistory();
const dispatch = useContext(WorkflowDispatchContext);
const { values, setTouched, validateForm } = useFormikContext();
const { values, setFieldTouched } = useFormikContext();
const [triggerNext, setTriggerNext] = useState(0);
@ -60,6 +60,7 @@ function NodeModalForm({
const {
steps: promptSteps,
validateStep,
visitStep,
visitAllSteps,
contentError,
@ -192,24 +193,27 @@ function NodeModalForm({
onSave={() => {
handleSaveNode();
}}
onBack={async nextStep => {
validateStep(nextStep.id);
}}
onGoToStep={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') {
visitAllSteps(setTouched);
visitAllSteps(setFieldTouched);
} else {
visitStep(prevStep.prevId);
visitStep(prevStep.prevId, setFieldTouched);
validateStep(nextStep.id);
}
await validateForm();
}}
steps={promptSteps}
css="overflow: scroll"
title={title}
onNext={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') {
visitAllSteps(setTouched);
visitAllSteps(setFieldTouched);
} else {
visitStep(prevStep.prevId);
visitStep(prevStep.prevId, setFieldTouched);
validateStep(nextStep.id);
}
await validateForm();
}}
/>
);

View File

@ -17,12 +17,11 @@ export default function useNodeTypeStep(i18n) {
initialValues: getInitialValues(),
isReady: true,
contentError: null,
formError: meta.error,
setTouched: setFieldsTouched => {
setFieldsTouched({
inventory: true,
});
hasError: !!meta.error,
setTouched: setFieldTouched => {
setFieldTouched('nodeType', true, false);
},
validate: () => {},
};
}
function getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField) {

View File

@ -14,12 +14,11 @@ export default function useRunTypeStep(i18n, askLinkType) {
initialValues: askLinkType ? { linkType: 'success' } : {},
isReady: true,
contentError: null,
formError: meta.error,
setTouched: setFieldsTouched => {
setFieldsTouched({
inventory: true,
});
hasError: !!meta.error,
setTouched: setFieldTouched => {
setFieldTouched('linkType', true, false);
},
validate: () => {},
};
}
function getStep(askLinkType, meta, i18n) {

View File

@ -194,7 +194,8 @@ export default function useWorkflowNodeSteps(
useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited),
];
const hasErrors = steps.some(step => step.formError);
const hasErrors = steps.some(step => step.hasError);
steps.push(
usePreviewStep(
launchConfig,
@ -250,12 +251,17 @@ export default function useWorkflowNodeSteps(
return {
steps: pfSteps,
visitStep: stepId =>
validateStep: stepId => {
steps.find(s => s?.step?.id === stepId).validate();
},
visitStep: (prevStepId, setFieldTouched) => {
setVisited({
...visited,
[stepId]: true,
}),
visitAllSteps: setFieldsTouched => {
[prevStepId]: true,
});
steps.find(s => s?.step?.id === prevStepId).setTouched(setFieldTouched);
},
visitAllSteps: setFieldTouched => {
setVisited({
inventory: true,
credentials: true,
@ -263,7 +269,7 @@ export default function useWorkflowNodeSteps(
survey: true,
preview: true,
});
steps.forEach(s => s.setTouched(setFieldsTouched));
steps.forEach(s => s.setTouched(setFieldTouched));
},
contentError,
};