Skip to content

Commit

Permalink
Merge pull request #6698 from skateman/cloud-provider-forms
Browse files Browse the repository at this point in the history
Replace cloud provider forms with an API-driven DDF approach
  • Loading branch information
h-kataria authored May 28, 2020
2 parents 51f1087 + 4e9d756 commit 62f4ef8
Show file tree
Hide file tree
Showing 10 changed files with 379 additions and 55 deletions.
4 changes: 4 additions & 0 deletions app/helpers/ems_cloud_helper.rb
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
187 changes: 187 additions & 0 deletions app/javascript/components/provider-form/index.jsx
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 app/javascript/components/provider-form/protocol-selector.jsx
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 app/javascript/components/provider-form/provider-credentials.jsx
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 app/javascript/components/provider-form/provider-select-field.jsx
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;
Loading

0 comments on commit 62f4ef8

Please sign in to comment.