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

Replace cloud provider forms with an API-driven DDF approach #6698

Merged
merged 2 commits into from
May 28, 2020
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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';
Copy link
Contributor

@himdel himdel May 26, 2020

Choose a reason for hiding this comment

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

from @@ddf ? (and line 3)

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;
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;
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