-
Notifications
You must be signed in to change notification settings - Fork 356
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #6698 from skateman/cloud-provider-forms
Replace cloud provider forms with an API-driven DDF approach
- Loading branch information
Showing
10 changed files
with
379 additions
and
55 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,8 @@ | ||
module EmsCloudHelper | ||
include_concern 'TextualSummary' | ||
include_concern 'ComplianceSummaryHelper' | ||
|
||
def edit_redirect_path(lastaction, ems) | ||
lastaction == 'show_list' ? ems_clouds_path : ems_cloud_path(ems) | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,187 @@ | ||
import React, { useState, useEffect } from 'react'; | ||
import PropTypes from 'prop-types'; | ||
import { componentTypes, validatorTypes } from '@data-driven-forms/react-form-renderer'; | ||
import { pick, keyBy } from 'lodash'; | ||
|
||
import { API } from '../../http_api'; | ||
import MiqFormRenderer from '../../forms/data-driven-form'; | ||
import miqRedirectBack from '../../helpers/miq-redirect-back'; | ||
import fieldsMapper from '../../forms/mappers/formFieldsMapper'; | ||
import ProtocolSelector from './protocol-selector'; | ||
import ProviderSelectField from './provider-select-field'; | ||
import ProviderCredentials from './provider-credentials'; | ||
import ValidateProviderCredentials from './validate-provider-credentials'; | ||
|
||
const findSkipSubmits = (schema, items) => { | ||
const found = schema.skipSubmit && items.includes(schema.name) ? [schema.name] : []; | ||
const children = Array.isArray(schema.fields) ? schema.fields.flatMap(field => findSkipSubmits(field, items)) : []; | ||
return [...found, ...children]; | ||
}; | ||
|
||
const typeSelectField = (edit, filter) => ({ | ||
component: 'provider-select-field', | ||
name: 'type', | ||
label: __('Type'), | ||
kind: filter, | ||
isDisabled: edit, | ||
loadOptions: () => | ||
API.options('/api/providers').then(({ data: { supported_providers } }) => supported_providers // eslint-disable-line camelcase | ||
.filter(({ kind }) => kind === filter) | ||
.map(({ title, type }) => ({ value: type, label: title }))), | ||
}); | ||
|
||
const commonFields = [ | ||
{ | ||
component: componentTypes.TEXT_FIELD, | ||
name: 'name', | ||
label: __('Name'), | ||
isRequired: true, | ||
validate: [{ | ||
type: validatorTypes.REQUIRED, | ||
}], | ||
}, | ||
{ | ||
component: componentTypes.SELECT, | ||
name: 'zone_id', | ||
label: __('Zone'), | ||
loadOptions: () => | ||
API.get('/api/zones?expand=resources&attributes=id,name,visible&filter[]=visible!=false&sort_by=name') | ||
.then(({ resources }) => resources.map(({ id: value, name: label }) => ({ value, label }))), | ||
isRequired: true, | ||
validate: [{ | ||
type: validatorTypes.REQUIRED, | ||
}], | ||
}, | ||
]; | ||
|
||
export const loadProviderFields = (kind, type) => API.options(`/api/providers?type=${type}`).then( | ||
({ data: { provider_form_schema } }) => ([ // eslint-disable-line camelcase | ||
...commonFields, | ||
{ | ||
component: componentTypes.SUB_FORM, | ||
name: type, | ||
...provider_form_schema, // eslint-disable-line camelcase | ||
}, | ||
]), | ||
); | ||
|
||
export const EditingContext = React.createContext({}); | ||
|
||
const ProviderForm = ({ providerId, kind, title, redirect }) => { | ||
const edit = !!providerId; | ||
const [{ fields, initialValues }, setState] = useState({ fields: edit ? undefined : [typeSelectField(false, kind)] }); | ||
|
||
const submitLabel = edit ? __('Save') : __('Add'); | ||
|
||
useEffect(() => { | ||
if (providerId) { | ||
miqSparkleOn(); | ||
API.get(`/api/providers/${providerId}?attributes=endpoints,authentications`).then(({ | ||
type, | ||
endpoints: _endpoints, | ||
authentications: _authentications, | ||
...provider | ||
}) => { | ||
// DDF can handle arrays with FieldArray, but only with a heterogenous schema, which isn't enough. | ||
// As a solution, we're converting the arrays to objects indexed by role/authtype and converting | ||
// it back to an array of objects before submitting the form. Validation, however, should not be | ||
// converted back as the schema is being used in the password sanitization process. | ||
const endpoints = keyBy(_endpoints, 'role'); | ||
const authentications = keyBy(_authentications, 'authtype'); | ||
|
||
loadProviderFields(kind, type).then((fields) => { | ||
setState({ | ||
fields: [typeSelectField(true, kind), ...fields], | ||
initialValues: { | ||
...provider, | ||
type, | ||
endpoints, | ||
authentications, | ||
}, | ||
}); | ||
}).then(miqSparkleOff); | ||
}); | ||
} | ||
}, [providerId]); | ||
|
||
const onCancel = () => { | ||
const message = sprintf(providerId ? __('Edit of %s was cancelled by the user') : __('Add of %s was cancelled by the user'), title); | ||
miqRedirectBack(message, 'success', redirect); | ||
}; | ||
|
||
const onSubmit = ({ type, ..._data }, { getState }) => { | ||
miqSparkleOn(); | ||
|
||
const message = sprintf(__('%s %s was saved'), title, _data.name || initialValues.name); | ||
|
||
// Retrieve the modified fields from the schema | ||
const modified = Object.keys(getState().modified); | ||
// Imit the fields that have `skipSubmit` set to `true` | ||
const toDelete = findSkipSubmits({ fields }, modified); | ||
// Construct a list of fields to be submitted | ||
const toSubmit = modified.filter(field => !toDelete.includes(field)); | ||
|
||
// Build up the form data using the list and pull out endpoints and authentications | ||
const { endpoints: _endpoints = { default: {} }, authentications: _authentications = {}, ...rest } = pick(_data, toSubmit); | ||
// Convert endpoints and authentications back to an array | ||
const endpoints = Object.keys(_endpoints).map(key => ({ role: key, ..._endpoints[key] })); | ||
const authentications = Object.keys(_authentications).map(key => ({ authtype: key, ..._authentications[key] })); | ||
|
||
// Construct the full form data with all the necessary items | ||
const data = { | ||
...rest, | ||
endpoints, | ||
authentications, | ||
...(edit ? undefined : { type }), | ||
ddf: true, | ||
}; | ||
|
||
const request = providerId ? API.patch(`/api/providers/${providerId}`, data) : API.post('/api/providers', data); | ||
request.then(() => miqRedirectBack(message, 'success', redirect)).catch(miqSparkleOff); | ||
}; | ||
|
||
const formFieldsMapper = { | ||
...fieldsMapper, | ||
'protocol-selector': ProtocolSelector, | ||
'provider-select-field': ProviderSelectField, | ||
'provider-credentials': ProviderCredentials, | ||
'validate-provider-credentials': ValidateProviderCredentials, | ||
}; | ||
|
||
return ( | ||
<div> | ||
{ fields && ( | ||
<EditingContext.Provider value={{ providerId, setState }}> | ||
<MiqFormRenderer | ||
formFieldsMapper={formFieldsMapper} | ||
schema={{ fields }} | ||
onSubmit={onSubmit} | ||
onCancel={onCancel} | ||
onReset={() => add_flash(__('All changes have been reset'), 'warn')} | ||
initialValues={initialValues} | ||
clearedValue={null} | ||
buttonsLabels={{ submitLabel }} | ||
canReset={edit} | ||
clearOnUnmount | ||
/> | ||
</EditingContext.Provider> | ||
) } | ||
</div> | ||
); | ||
}; | ||
|
||
ProviderForm.propTypes = { | ||
providerId: PropTypes.string, | ||
kind: PropTypes.string, | ||
title: PropTypes.string, | ||
redirect: PropTypes.string, | ||
}; | ||
|
||
ProviderForm.defaultProps = { | ||
providerId: undefined, | ||
kind: undefined, | ||
title: undefined, | ||
redirect: undefined, | ||
}; | ||
|
||
export default ProviderForm; |
95 changes: 95 additions & 0 deletions
95
app/javascript/components/provider-form/protocol-selector.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
import React, { useState, useContext } from 'react'; | ||
import PropTypes from 'prop-types'; | ||
|
||
import fieldsMapper from '../../forms/mappers/formFieldsMapper'; | ||
import { EditingContext } from './index'; | ||
|
||
const Component = fieldsMapper['select-field']; | ||
|
||
const filter = (items, toDelete) => Object.keys(items).filter(key => !toDelete.includes(key)).reduce((obj, key) => ({ | ||
...obj, | ||
[key]: items[key], | ||
}), {}); | ||
|
||
// This is a special <Select> component that allows altering underlying endpoints/authentications which is intended to | ||
// be used when there's a variety of protocols for a given implemented service. For example event stream collection in | ||
// some providers allows the user to choose from multiple protocols. | ||
const ProtocolSelector = ({ | ||
FieldProvider, formOptions, initialValue, ...props | ||
}) => { | ||
const [loaded, setLoaded] = useState(false); | ||
const { providerId } = useContext(EditingContext); | ||
|
||
return ( | ||
<FieldProvider | ||
formOptions={formOptions} | ||
render={({ input: { name, onChange, ...input }, options, ...rest }) => { | ||
const fieldState = formOptions.getFieldState(name); | ||
|
||
// Run this on the first render with a usable state | ||
if (!loaded && fieldState) { | ||
// If editing an existing provider, we need to determine which endpoint protocol is being used. | ||
// This is done by checking against the pivot field for each option. If a related the pivot is | ||
// set, it means that the endpoint is used. If there is no pivot selected, we fall back to the | ||
// defined initialValue. | ||
if (!!providerId) { | ||
const selected = options.find(({ pivot }) => _.get(formOptions.getState().values, pivot)); | ||
const value = selected ? selected.value : initialValue; | ||
|
||
// Reinitializing the form with the correct value for the select | ||
setTimeout(() => { | ||
formOptions.initialize({ | ||
...formOptions.getState().values, | ||
[name]: value, | ||
}); | ||
}); | ||
} | ||
|
||
setLoaded(true); | ||
} | ||
|
||
const enhancedChange = onChange => (value) => { | ||
setTimeout(() => { | ||
// Load the initial and current values for the endpoints/authentications after the field value has been changed | ||
const { | ||
initialValues: { | ||
endpoints: initialEndpoints = {}, | ||
authentications: initialAuthentications = {}, | ||
}, | ||
values: { | ||
endpoints: currentEndpoints = {}, | ||
authentications: currentAuthentications = {}, | ||
}, | ||
} = formOptions.getState(); | ||
|
||
// Map the values of all possible options into an array | ||
const optionValues = options.map(({ value }) => value); | ||
// Determine which endpoint/authentication pair has to be removed from the form state | ||
const toDelete = optionValues.filter(option => option !== value); | ||
|
||
// Adjust the endpoints/authentications and pass them to the form state | ||
formOptions.change('endpoints', { | ||
...filter(initialEndpoints, toDelete), | ||
...filter(currentEndpoints, optionValues), | ||
}); | ||
formOptions.change('authentications', { | ||
...filter(initialAuthentications, toDelete), | ||
...filter(currentAuthentications, optionValues), | ||
}); | ||
}); | ||
|
||
return onChange(value); | ||
}; | ||
|
||
return <Component input={{ name, ...input, onChange: enhancedChange(onChange) }} options={options} {...rest} />; | ||
}} | ||
{...props} | ||
/> | ||
); | ||
}; | ||
|
||
ProtocolSelector.propTypes = { | ||
FieldProvider: PropTypes.func.isRequired, | ||
}; | ||
|
||
export default ProtocolSelector; |
27 changes: 27 additions & 0 deletions
27
app/javascript/components/provider-form/provider-credentials.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
import React, { Fragment, useContext } from 'react'; | ||
import PropTypes from 'prop-types'; | ||
|
||
import { EditingContext } from './index'; | ||
|
||
const ProviderCredentials = ({ formOptions, fields }) => { | ||
const { providerId } = useContext(EditingContext); | ||
|
||
// Pass down the required `edit` to the password component (if it exists) | ||
return ( | ||
<Fragment> | ||
{ | ||
formOptions.renderForm(fields.map(field => ({ | ||
...field, | ||
...(field.component === 'password-field' ? { edit: !!providerId } : undefined), | ||
})), formOptions) | ||
} | ||
</Fragment> | ||
); | ||
}; | ||
|
||
ProviderCredentials.propTypes = { | ||
formOptions: PropTypes.any.isRequired, | ||
fields: PropTypes.array.isRequired, | ||
}; | ||
|
||
export default ProviderCredentials; |
44 changes: 44 additions & 0 deletions
44
app/javascript/components/provider-form/provider-select-field.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
import React, { useContext } from 'react'; | ||
import { set } from 'lodash'; | ||
|
||
import fieldsMapper from '../../forms/mappers/formFieldsMapper'; | ||
import { EditingContext, loadProviderFields } from './index'; | ||
|
||
const extractInitialValues = ({ name, initialValue, fields }) => { | ||
const children = fields ? fields.reduce((obj, field) => ({ ...obj, ...extractInitialValues(field) }), {}) : {}; | ||
const item = name && initialValue ? { [name]: initialValue } : undefined; | ||
return { ...item, ...children }; | ||
}; | ||
|
||
const ProviderSelectField = ({ kind, FieldProvider, formOptions, ...props }) => { | ||
const { isDisabled: edit } = props; | ||
|
||
const { setState } = useContext(EditingContext); | ||
const Component = fieldsMapper['select-field']; | ||
|
||
const enhancedChange = onChange => (type) => { | ||
if (!edit && type) { | ||
miqSparkleOn(); | ||
|
||
loadProviderFields(kind, type).then((fields) => { | ||
setState(({ fields: [firstField] }) => ({ fields: [firstField, ...fields] })); | ||
const initialValues = extractInitialValues({ fields }); | ||
formOptions.initialize(Object.keys(initialValues).reduce((obj, key) => set(obj, key, initialValues[key]), { type })); | ||
}).then(miqSparkleOff); | ||
} | ||
|
||
return onChange(type); | ||
}; | ||
|
||
return ( | ||
<FieldProvider | ||
{...props} | ||
formOptions={formOptions} | ||
render={({ input: { onChange, ...input }, ...props }) => ( | ||
<Component input={{ ...input, onChange: enhancedChange(onChange) }} formOptions={formOptions} {...props} /> | ||
)} | ||
/> | ||
); | ||
}; | ||
|
||
export default ProviderSelectField; |
Oops, something went wrong.