diff --git a/app/controllers/catalog_controller.rb b/app/controllers/catalog_controller.rb index e654f8b4503..532c300bc4a 100644 --- a/app/controllers/catalog_controller.rb +++ b/app/controllers/catalog_controller.rb @@ -739,6 +739,9 @@ def svc_catalog_provision ra, st, svc_catalog_provision_finish_submit_endpoint ) @in_a_form = true + @dialog_locals = options[:dialog_locals] + # require 'byebug' + # byebug replace_right_cell(:action => "dialog_provision", :dialog_locals => options[:dialog_locals]) else # if catalog item has no dialog and provision button was pressed from list view diff --git a/app/controllers/service_controller.rb b/app/controllers/service_controller.rb index 78123c7911e..81adce03be8 100644 --- a/app/controllers/service_controller.rb +++ b/app/controllers/service_controller.rb @@ -33,6 +33,8 @@ def button service_retire when 'service_retire_now' service_retire_now + when 'service_reconfigure' + javascript_redirect(:action => 'service_reconfigure', :id => params[:id]) end end @@ -56,20 +58,25 @@ def show_generic_object def edit assert_privileges("service_edit") + + $log.warn("testing edit") + checked = find_checked_items checked[0] = params[:id] if checked.blank? && params[:id] @service = find_record_with_rbac(Service, checked[0]) @in_a_form = true - drop_breadcrumb(:name => _("Edit Service\"%{name}\"") % {:name => @service.name}, :url => "/service/edit/#{@service.id}") + drop_breadcrumb(:name => _("Edit Service \"%{name}\"") % {:name => @service.name}, :url => "/service/edit/#{@service.id}") end def service_reconfigure - service = Service.find_by(:id => params[:id]) - service_template = service.service_template - resource_action = service_template.resource_actions.find_by(:action => 'Reconfigure') if service_template + assert_privileges('service_reconfigure') - @right_cell_text = _("Reconfigure Service \"%{name}\"") % {:name => service.name} - dialog_locals = {:resource_action_id => resource_action.id, :target_id => service.id} + @service = find_record_with_rbac(Service, params[:id]) + service_template = @service.service_template + resource_action = service_template.resource_actions.find_by(:action => 'Reconfigure') if service_template + @dialog_locals = {:resource_action_id => resource_action.id, :target_id => @service.id} + @in_a_form = true + drop_breadcrumb(:name => _("Reconfigure Service \"%{name}\"") % {:name => @service.name}, :url => "/service/service_reconfigure/#{@service.id}") end def service_form_fields @@ -211,8 +218,8 @@ def set_right_cell_vars(action) action = "retire" when "reconfigure_dialog" partial = "shared/dialogs/reconfigure_dialog" - header = @right_cell_text - action = nil + header = _("Reconfigure Service \"%{name}\"") % {:name => @service.name} + action = "reconfigure_dialog" when "service_edit" partial = "service_form" header = _("Editing Service \"%{name}\"") % {:name => @service.name} diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 1c423df56d7..14ca4f6e301 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -14,6 +14,7 @@ module ApplicationHelper include Title include ReactjsHelper include Webpack + include OrderServiceHelper VALID_PERF_PARENTS = { "EmsCluster" => :ems_cluster, diff --git a/app/helpers/miq_request_helper.rb b/app/helpers/miq_request_helper.rb index 418ebf678fc..a67a18459be 100644 --- a/app/helpers/miq_request_helper.rb +++ b/app/helpers/miq_request_helper.rb @@ -1,6 +1,7 @@ module MiqRequestHelper include RequestInfoHelper include RequestDetailsHelper + include RequestDialogOptionsHelper def row_data(label, value) {:cells => {:label => label, :value => value}} diff --git a/app/helpers/order_service_helper.rb b/app/helpers/order_service_helper.rb new file mode 100644 index 00000000000..fae935cb9cc --- /dev/null +++ b/app/helpers/order_service_helper.rb @@ -0,0 +1,17 @@ +module OrderServiceHelper + def order_service_data(dialog) + { + + :apiSubmitEndpoint => dialog[:api_submit_endpoint], + :apiAction => dialog[:api_action], + :cancelEndPoint => dialog[:cancel_endpoint], + :dialogId => dialog[:dialog_id], + :finishSubmitEndpoint => dialog[:finish_submit_endpoint], + :openUrl => dialog[:open_url], + :resourceActionId => dialog[:resource_action_id], + :realTargetType => dialog[:real_target_type], + :targetId => dialog[:target_id], + :targetType => dialog[:target_type], + } + end +end diff --git a/app/helpers/request_dialog_options_helper.rb b/app/helpers/request_dialog_options_helper.rb new file mode 100644 index 00000000000..b4829d1dfc4 --- /dev/null +++ b/app/helpers/request_dialog_options_helper.rb @@ -0,0 +1,66 @@ +module RequestDialogOptionsHelper + def request_dialog_options(workflow) + data = workflow.dialog.dialog_tabs.map do |tab, _tab_index| + { + :label => _(tab.label), + :content => request_dialog_options_groups(tab.dialog_groups, workflow) + } + end + react('RequestDialogOptions', {:data => data}) + end + + private + + def request_dialog_options_groups(dialog_groups, workflow) + dialog_groups.map do |group| + { + :title => _(group.label), + :rows => request_dialog_options_fields(group.dialog_fields, workflow), + :mode => "miq_summary request_dialog_options" + } + end + end + + def request_dialog_options_fields(dialog_fields, workflow) + dialog_fields.map do |field| + row_data(_(field.label), request_dialog_options_field_content(field, workflow)) + end + end + + def request_dialog_options_field_content(field, workflow) + case field.type + when 'DialogFieldTextBox', 'DialogFieldTextAreaBox' + if field.protected? + "********" + else + field.value + end + + when 'DialogFieldCheckBox' + {:input => "checkbox", :name => field.name, :checked => field.checked?, :disabled => true, :label => ''} + + when 'DialogFieldDateControl' + field.value + + when 'DialogFieldDateTimeControl' + date_val, time_val = field.value.split + hour_val, minute_val = time_val.split(":") + "#{date_val} at #{hour_val.rjust(2, '0')}:#{minute_val.rjust(2, '0')} #{session[:user_tz]}" + + when "DialogFieldRadioButton" + field.values.detect { |k, _v| k == workflow.value(field.name) }.try(:last) || workflow.value(field.name) + + when "DialogFieldDropDownList" + field.value.blank? ? _("") : field.value.to_s.gsub("\x1F", ", ") + + when 'DialogFieldTagControl' + value = workflow.value(field.name) || '' # it returns in format Clasification::id + classifications = value.split(',').map { |c| Classification.find_by(:id => c.split('::').second).description } + if classifications.empty? + '' + else + classifications.join(', ') + end + end + end +end diff --git a/app/javascript/components/order-service-form/index.jsx b/app/javascript/components/order-service-form/index.jsx new file mode 100644 index 00000000000..25063ec2058 --- /dev/null +++ b/app/javascript/components/order-service-form/index.jsx @@ -0,0 +1,173 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import MiqFormRenderer, { useFormApi } from '@@ddf'; +import { Button, Loading } from 'carbon-components-react'; +import { FormSpy } from '@data-driven-forms/react-form-renderer'; +import createSchema from '../service-dialog-builder/service-dialog-builder.schema'; +import { API } from '../../http_api'; +import miqRedirectBack from '../../helpers/miq-redirect-back'; +import { buildFields, prepareSubmitData } from '../service-dialog-builder/helper'; +import ServiceDialogRefreshButton from '../service-dialog-builder/service-dialog-refresh-button'; +import mapper from '../../forms/mappers/componentMapper'; +import NotificationMessage from '../notification-message'; + +const OrderServiceForm = ({ initialData }) => { + const { + dialogId, resourceActionId, targetId, targetType, apiSubmitEndpoint, apiAction, openUrl, realTargetType, finishSubmitEndpoint, + } = initialData; + + const [data, setData] = useState({ + isLoading: true, + fields: [], + hasTime: false, + showPastDates: [], + showPastDatesFieldErrors: [], + dateErrorFields: [], + checkBoxes: [], + dates: [], + refreshDialogFields: {}, + notification: undefined, + }); + const [showDateError, setShowDateError] = useState([]); + + useEffect(() => { + const url = `/api/service_dialogs/${dialogId}?resource_action_id=${resourceActionId}&target_id=${targetId}&target_type=${targetType}`; + API.get(url, { skipErrors: [500] }) + .then((response) => { + buildFields(response, data, setData, initialData); + }) + .catch((errorData) => { + setData({ + ...data, + notification: { type: 'error', message: errorData.data.error }, + }); + }); + }, []); + + const onSubmit = (values) => { + let submitData = prepareSubmitData('order', values, setShowDateError); + + if (submitData !== false) { + if (apiSubmitEndpoint.includes('/generic_objects/')) { + submitData = { action: apiAction, parameters: _.omit(submitData, 'action') }; + } else if (apiAction === 'reconfigure') { + submitData = { action: apiAction, resource: _.omit(submitData, 'action') }; + } + return API.post(apiSubmitEndpoint, submitData, { skipErrors: [400] }) + .then((response) => { + if (openUrl === 'true') { + return API.wait_for_task(response) + .then(() => + // eslint-disable-next-line no-undef + $http.post('open_url_after_dialog', { targetId, realTargetType })) + .then((taskResponse) => { + if (taskResponse.data.open_url) { + window.open(response.data.open_url); + miqRedirectBack(__('Order Request was Submitted'), 'success', finishSubmitEndpoint); + } else { + add_flash(__('Automate failed to obtain URL.'), 'error'); + miqSparkleOff(); + } + }); + } + miqRedirectBack(__('Order Request was Submitted'), 'success', finishSubmitEndpoint); + return null; + }); + } + return null; + }; + + const onCancel = () => miqRedirectBack(__('Dialog Cancelled'), 'warning', '/catalog'); + + const componentMapper = { + ...mapper, + 'refresh-button': ServiceDialogRefreshButton, + }; + + return ( + <> + { + data.notification && + } + { + data.isLoading && ( +
+ +
+ ) + } + { + !data.isLoading && ( + } + componentMapper={componentMapper} + schema={createSchema(data.fields, showDateError)} + initialValues={data.initialValues} + onSubmit={onSubmit} + onCancel={onCancel} + /> + ) + } + + ); +}; + +const verifyIsDisabled = (valid) => { + let isDisabled = true; + if (valid) { + isDisabled = false; + } + return isDisabled; +}; + +const FormTemplate = ({ formFields }) => { + const { + handleSubmit, onCancel, getState, + } = useFormApi(); + const { values, valid } = getState(); + return ( +
+ {formFields} + + {() => ( +
+ + +
+ )} +
+
+ ); +}; + +FormTemplate.propTypes = { + formFields: PropTypes.arrayOf(PropTypes.any).isRequired, +}; + +OrderServiceForm.propTypes = { + initialData: PropTypes.shape({ + apiSubmitEndpoint: PropTypes.string.isRequired, + apiAction: PropTypes.string.isRequired, + cancelEndPoint: PropTypes.string.isRequired, + dialogId: PropTypes.number.isRequired, + finishSubmitEndpoint: PropTypes.string.isRequired, + openUrl: PropTypes.bool.isRequired, + resourceActionId: PropTypes.number.isRequired, + realTargetType: PropTypes.string.isRequired, + targetId: PropTypes.number.isRequired, + targetType: PropTypes.string.isRequired, + }).isRequired, +}; + +export default OrderServiceForm; diff --git a/app/javascript/components/request-dialog-options/index.jsx b/app/javascript/components/request-dialog-options/index.jsx new file mode 100644 index 00000000000..28331b2dfb6 --- /dev/null +++ b/app/javascript/components/request-dialog-options/index.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Tabs, Tab } from 'carbon-components-react'; +import MiqStructuredList from '../miq-structured-list'; + +/** Component to render the dialog options in Request/show page. */ +const RequestDialogOptions = ({ data }) => { + /** Function to render the tabs from the tabLabels props */ + const renderTabs = () => data.map(({ label, content }, tabIndex) => ( + + {content.map((item, contentIndex) => ( + + ))} + + )); + + return ( + + {renderTabs()} + + ); +}; + +export default RequestDialogOptions; + +RequestDialogOptions.propTypes = { + data: PropTypes.arrayOf(PropTypes.any).isRequired, +}; diff --git a/app/javascript/components/retirement-form/index.jsx b/app/javascript/components/retirement-form/index.jsx index 4cc37f37d37..808c9379a2b 100644 --- a/app/javascript/components/retirement-form/index.jsx +++ b/app/javascript/components/retirement-form/index.jsx @@ -97,6 +97,7 @@ const RetirementForm = ({ retirementTime = moment().startOf('D')._d; setShowTimeField(false); } + console.log(retires_on); setState({ isLoading: false, initialValues: retires_on ? { diff --git a/app/javascript/components/service-dialog-builder/dialog-fields-builder.js b/app/javascript/components/service-dialog-builder/dialog-fields-builder.js new file mode 100644 index 00000000000..5dbf4fb43df --- /dev/null +++ b/app/javascript/components/service-dialog-builder/dialog-fields-builder.js @@ -0,0 +1,352 @@ +import { componentTypes } from '@@ddf'; +// eslint-disable-next-line import/no-cycle +import { buildFields } from './helper'; + +let dynamicFields = {}; + +const generateDynamicFields = (field) => { + dynamicFields = { ...dynamicFields, [field.name]: null }; +}; + +/** Function to build a text box. */ +export const buildTextBox = (field, validate, apiAction) => { + let component = {}; + generateDynamicFields(field); + + if (field.options.protected) { + component = { + component: 'password-field', + id: field.id, + name: field.name, + label: field.label, + hideField: !field.visible, + isRequired: field.required, + isDisabled: field.read_only || (!field.reconfigurable && apiAction !== 'order'), + initialValue: field.default_value, + description: field.description, + validate, + }; + } else { + component = { + component: componentTypes.TEXT_FIELD, + id: field.id, + name: field.name, + label: field.label, + hideField: !field.visible, + isRequired: field.required, + isDisabled: field.read_only || (!field.reconfigurable && apiAction !== 'order'), + initialValue: field.default_value, + description: field.description, + validate, + resolveProps: (props, { meta, input }, formOptions) => { + dynamicFields[input.name] = input.value; + }, + // resolveProps: (props, { meta, input }, formOptions) => { + // console.log(props); + // console.log(meta); + // console.log(input); + // console.log(formOptions); + // if (!formOptions.pristine) { + // if (field.dialog_field_responders.length > 0) { + // field.dialog_field_responders.forEach((tempField) => { + // console.log(tempField); + // dynamicFields.forEach((fieldToRefresh) => { + // if (fieldToRefresh.field === tempField) { + // const refreshData = { + // action: 'refresh_dialog_fields', + // resource: { + // dialog_fields: { + // // credential: null, + // hosts: 'localhost0', + // // param_provider_id: '38', + // // param_miq_username: 'admin', + // // param_miq_password: 'smartvm', + // // check_box_1: 't', + // // dropdown_list_1_1: null, + // // textarea_box_1: '', + // // date_time_control_1: '2022-10-12T20:50:45.180Z', + // // date_time_control_2: '2022-10-12T20:50:45.180Z', + // // date_control_1: '2022-10-12', + // // date_control_2_1: '2022-09-27', + // }, + // fields: ['credential'], + // resource_action_id: '2018', + // target_id: '14', + // target_type: 'service_template', + // real_target_type: 'ServiceTemplate', + // }, + // }; + // fieldToRefresh.values = API.post(`/api/service_dialogs/10`, refreshData).then((data) => { + // console.log(data); + // }); + // console.log(fieldToRefresh); + // } + // }); + // }); + // } + // } + // }, + }; + } + return component; +}; + +/** Function to build a text area */ +export const buildTextAreaBox = (field, validate, apiAction) => ({ + component: componentTypes.TEXTAREA, + id: field.id, + name: field.name, + label: field.label, + hideField: !field.visible, + isRequired: field.required, + isDisabled: field.read_only || (!field.reconfigurable && apiAction !== 'order'), + initialValue: field.default_value, + description: field.description, + validate, +}); + +/** Function to build a check box. */ +export const buildCheckBox = (field, validate, apiAction) => ({ + component: componentTypes.CHECKBOX, + id: field.id, + name: field.name, + label: field.label, + hideField: !field.visible, + isRequired: field.required, + isDisabled: field.read_only || (!field.reconfigurable && apiAction !== 'order'), + initialValue: field.default_value, + description: field.description, + validate, +}); + +/** Function to build a drop down select box. */ +export const buildDropDownList = (field, validate, apiAction) => { + let options = []; + let placeholder = __(''); + let start; + + field.values.forEach((value) => { + if (value[0] === null) { + value[0] = null; + // eslint-disable-next-line prefer-destructuring + placeholder = value[1]; + } + options.push({ value: value[0] !== null ? String(value[0]) : null, label: value[1] }); + }); + + if (options[0].value === null) { + start = options.shift(); + } + options = options.sort((option1, option2) => { + if (field.options.sort_by === 'description') { + if (field.options.sort_order === 'ascending') { + return option1.label.localeCompare(option2.label); + } + return option2.label.localeCompare(option1.label); + } + if (field.options.sort_order === 'ascending') { + return option1.value.localeCompare(option2.value); + } + return option2.value.localeCompare(option1.value); + }); + if (start) { + options.unshift(start); + } + + let isMulti = false; + if (field.options && field.options.force_multi_value) { + isMulti = true; + } + generateDynamicFields(field); + return { + component: componentTypes.SELECT, + id: field.id, + name: field.name, + labelText: field.label, + hideField: !field.visible, + isRequired: field.required, + isDisabled: field.read_only || (!field.reconfigurable && apiAction !== 'order'), + initialValue: field.default_value, + description: field.description, + validate, + resolveProps: (props, { meta, input }, formOptions) => { + dynamicFields[input.name] = input.value; + }, + options, + placeholder, + isSearchable: true, + simpleValue: true, + isMulti, + }; +}; + +/** Function to build a tag control field. */ +export const buildTagControl = (field, validate, apiAction) => { + const options = []; + field.values.forEach((value) => { + if (!value.id) { + value.id = '-1'; + } + options.push({ value: value.id, label: value.description }); + }); + return { + component: componentTypes.SELECT, + id: field.id, + name: field.name, + label: field.label, + hideField: !field.visible, + isRequired: field.required, + isDisabled: field.read_only || (!field.reconfigurable && apiAction !== 'order'), + initialValue: field.default_value, + description: field.description, + validate, + options, + }; +}; + +/** Function to build a date control field */ +export const buildDateControl = (field, validate, apiAction) => ({ + component: componentTypes.DATE_PICKER, + id: field.id, + name: field.name, + label: field.label, + isRequired: field.required, + isDisabled: field.read_only || (!field.reconfigurable && apiAction !== 'order'), + initialValue: field.default_value, + description: field.description, + validate, + variant: 'date-time', +}); + +/** Function to build a time control field */ +export const buildTimeControl = (field, validate, dateTime, apiAction) => ([{ + component: componentTypes.DATE_PICKER, + id: field.id, + name: field.name, + label: field.label, + isRequired: field.required, + isDisabled: field.read_only || (!field.reconfigurable && apiAction !== 'order'), + initialValue: dateTime.toISOString(), + description: field.description, + validate, + variant: 'date-time', +}, +{ + component: componentTypes.TIME_PICKER, + id: `${field.id}-time`, + name: `${field.name}-time`, + isRequired: field.required, + isDisabled: field.read_only || (!field.reconfigurable && apiAction !== 'order'), + initialValue: dateTime, + validate, + twelveHoursFormat: true, + pattern: '(0?[1-9]|1[0-2]):[0-5][0-9]', +}]); + +/** Function to build radio buttons fields */ +export const buildRadioButtons = (field, validate, apiAction) => { + const options = []; + field.values.forEach((value) => { + options.push({ value: value[0], label: value[1] }); + }); + return { + component: componentTypes.RADIO, + id: field.id, + name: field.name, + label: field.label, + isRequired: field.required, + isDisabled: field.read_only || (!field.reconfigurable && apiAction !== 'order'), + initialValue: field.default_value, + description: field.description, + validate, + options, + }; +}; + +/** Function to show/hide loaders near to the fields. */ +const fieldSpinner = (fieldName, show) => { + const activeSpinner = document.getElementById(`refreshSpinner_${fieldName}`); + activeSpinner.style.display = show ? 'block' : 'none'; +}; + +/** Function to update the response and build the fileds again after field refresh. */ +const updateResponseFields = (response, fieldPosition, fieldName, result) => { + const responseContent = response.content ? response.content[0].dialog_tabs : response.reconfigure_dialog[0].dialog_tabs; + responseContent.map((tab, tabIndex) => { + if (tabIndex === fieldPosition.tabIndex) { + tab.dialog_groups.map((group, groupIndex) => { + if (groupIndex === fieldPosition.groupIndex) { + const field = group.dialog_fields.find((item) => item.name === fieldName); + const data = result[fieldName]; + field.data_type = data.data_type; + field.options = data.options; + field.read_only = data.read_only; + field.reconfigurable = data.reconfigurable; + field.required = data.required; + field.visible = data.visible; + field.values = data.values; + field.default_value = data.default_value; + field.validator_rule = data.validator_rule; + field.validator_type = data.validator_type; + } + return group; + }); + } + return tab; + }); + return response; +}; + +/** Function to fetch the field information and update the field value. + * If another field is linked, the same function is called again to update the linked field. +*/ +const refreshFields = (response, params, fieldName, initialData, resource, data, setData, fieldPosition) => { + fieldSpinner(fieldName, true); + + const dialogId = response.reconfigure_dialog ? response.reconfigure_dialog[0].id : response.id; + + API.post(`/api/service_dialogs/${dialogId}`, params).then(({ result }) => { + const responders = result[fieldName].dialog_field_responders; + const newResponse = updateResponseFields(response, fieldPosition, fieldName, result); + buildFields(newResponse, data, setData, initialData); + responders.forEach((responderName) => { + const newParams = { + ...params, + resource: { ...resource, fields: [responderName] }, + }; + fieldSpinner(fieldName, false); + refreshFields(response, newParams, responderName, initialData, resource, data, setData, fieldPosition); + }); + }); +}; + +/** Function to handle the the refresh button click event. */ +const onRefreshField = (response, field, initialData, data, setData, fieldPosition) => { + const resource = { + dialog_fields: dynamicFields, + fields: [field.name], + resource_action_id: initialData.resourceActionId, + target_id: initialData.targetId, + target_type: initialData.targetType ? initialData.targetType : 'service', + real_target_type: initialData.realTargetType, + }; + const params = { + action: 'refresh_dialog_fields', + resource, + }; + + refreshFields(response, params, field.name, initialData, resource, data, setData, fieldPosition); +}; + +/** Function to build a refresh button near to drop down. */ +export const buildRefreshButton = (response, field, initialData, data, setData, fieldPosition) => ({ + component: 'refresh-button', + id: `refresh_${field.id}`, + name: `refresh_${field.name}`, + label: 'Refresh', + hideField: !field.visible, + className: 'refresh-button', + showRefreshButton: !!(field.dynamic && field.show_refresh_button), + fieldName: field.name, + onRefresh: () => onRefreshField(response, field, initialData, data, setData, fieldPosition), +}); diff --git a/app/javascript/components/service-dialog-builder/helper.js b/app/javascript/components/service-dialog-builder/helper.js new file mode 100644 index 00000000000..48156d6ce90 --- /dev/null +++ b/app/javascript/components/service-dialog-builder/helper.js @@ -0,0 +1,303 @@ +import { componentTypes, validatorTypes } from '@@ddf'; +// eslint-disable-next-line import/no-cycle +import { + buildTextBox, + buildTextAreaBox, + buildCheckBox, + buildDropDownList, + buildTagControl, + buildDateControl, + buildTimeControl, + buildRadioButtons, + buildRefreshButton, +} from './dialog-fields-builder'; + +const dates = []; +const showPastDates = []; +const showPastDatesFieldErrors = []; +const checkBoxes = []; +let hasTime = false; +let stopSubmit = false; +let invalidDateFields = []; + +const DIALOG_FIELDS = { + checkBox: 'DialogFieldCheckBox', + date: 'DialogFieldDateControl', + dateTime: 'DialogFieldDateTimeControl', + dropDown: 'DialogFieldDropDownList', + radio: 'DialogFieldRadioButton', + tag: 'DialogFieldTagControl', + textBox: 'DialogFieldTextBox', + textArea: 'DialogFieldTextAreaBox', +}; + +const formatDateControl = (field) => { + dates.push(field.name); + if (field.default_value === '' || !field.default_value) { + const today = new Date(); + field.default_value = today.toISOString(); + } + if (field.options.show_past_dates) { + showPastDates.push(field.name); + } else { + showPastDatesFieldErrors.push({ name: field.name, label: field.label }); + } +}; + +const formatTimeControl = (field) => { + let newDate = ''; + hasTime = true; + if (field.default_value === '' || !field.default_value) { + newDate = new Date(); + field.default_value = newDate.toISOString(); + } else { + newDate = new Date(field.default_value); + } + if (field.options.show_past_dates) { + showPastDates.push(field.name); + } else { + showPastDatesFieldErrors.push({ name: field.name, label: field.label }); + } + return newDate; +}; + +const buildComponent = (field, validate, apiAction) => { + switch (field.type) { + case DIALOG_FIELDS.textBox: + return buildTextBox(field, validate, apiAction); + case DIALOG_FIELDS.textArea: + return buildTextAreaBox(field, validate, apiAction); + case DIALOG_FIELDS.checkBox: + return buildCheckBox(field, validate, apiAction); + case DIALOG_FIELDS.dropDown: + return buildDropDownList(field, validate, apiAction); + case DIALOG_FIELDS.tag: + return buildTagControl(field, validate, apiAction); + case DIALOG_FIELDS.date: + { + formatDateControl(field); + return buildDateControl(field, validate, apiAction); + } + case DIALOG_FIELDS.dateTime: + { + const dateTime = formatTimeControl(field); + return buildTimeControl(field, validate, dateTime, apiAction); + } + case DIALOG_FIELDS.radio: + return buildRadioButtons(field, validate, apiAction); + default: + return {}; + } +}; + +/** Function to build a field inside a section. */ +const sectionField = (group, componentItem, index) => ({ + component: componentTypes.SUB_FORM, + id: `${group.id.toString()}_row`, + name: group.label, + fields: componentItem, + className: 'order-form-row', + key: index, +}); + +/** Function to build the validators of a field. */ +const buildValidator = (field) => { + const validate = []; + if (field.validator_rule) { + if (field.validator_message) { + validate.push({ + type: validatorTypes.PATTERN, + pattern: field.validator_rule, + message: field.validator_message, + }); + } else { + validate.push({ + type: validatorTypes.PATTERN, + pattern: field.validator_rule, + }); + } + } + if (field.required) { + validate.push({ + type: validatorTypes.REQUIRED, + }); + } +}; + +/** Function to build the form fields. */ +export const buildFields = (response, data, setData, initialData) => { + const dialogTabs = []; + const responseContent = response.content ? response.content[0].dialog_tabs : response.reconfigure_dialog[0].dialog_tabs; + const { apiAction } = initialData; + + responseContent.forEach((tab, tabIndex) => { + const dialogSections = []; + tab.dialog_groups.forEach((group, groupIndex) => { + const dialogFields = []; + group.dialog_fields.forEach((field) => { + const validate = buildValidator(field); + const fieldPosition = { tabIndex, groupIndex }; + const fieldData = [ + buildComponent(field, validate, apiAction), + buildRefreshButton(response, field, initialData, data, setData, fieldPosition), + ]; + dialogFields.push(fieldData); + }); + + const sectionData = { + component: componentTypes.SUB_FORM, + id: group.id, + name: group.label, + title: group.label, + fields: dialogFields.map((item, index) => sectionField(group, item, index)), + }; + dialogSections.push(sectionData); + }); + + const tabData = { + name: tab.label, + title: tab.label, + fields: dialogSections, + }; + dialogTabs.push(tabData); + }); + setData({ + ...data, + fields: dialogTabs, + isLoading: false, + hasTime, + showPastDates, + showPastDatesFieldErrors, + checkBoxes, + dates, + }); +}; + +/** Function to reformat the dates. */ +const datePassed = (selectedDate) => { + const userDate = new Date(selectedDate); + const today = new Date(); + + if (userDate.getDate() === today.getDate() && userDate.getMonth() === today.getMonth() && userDate.getFullYear() === today.getFullYear()) { + return false; + } + + if (userDate < today) { + return true; + } + return false; +}; + +/** Function to handle the time picker format on submit. */ +const handleTimePickerSubmit = (submitData) => { + let tempSubmitData; + // Loop through fields to check for time fields + Object.entries(submitData).forEach((tempField) => { + let fieldName = `${tempField[0]}`; + let fieldValue = ''; + if (fieldName.includes('-time')) { + fieldName = fieldName.substring(0, fieldName.length - 5); + // eslint-disable-next-line prefer-destructuring + fieldValue = tempField[1]; + // If time field found loop through fields again to find corresponding date field + Object.entries(submitData).forEach((field) => { + if (field[0] === fieldName) { + const timeValue = new Date(fieldValue); + const dateValue = new Date(field[1]); + const newDate = new Date(dateValue.setHours(timeValue.getHours(), timeValue.getMinutes())); + submitData[field[0]] = newDate.toISOString(); // Set new date and time + + // Check for fields that don't allow previous dates + if (!showPastDates.includes(fieldName) && datePassed(newDate)) { + stopSubmit = true; + // Loop through all fields that don't allow previous dates + showPastDatesFieldErrors.forEach((dateField) => { + // Check if current field is found in the list of fields that don't allow previous dates + if (fieldName === dateField.name) { + // Add field label to list of invalid date fields + invalidDateFields.push(dateField.label); + } + }); + } + } + }); + tempSubmitData = _.omit(submitData, tempField[0]); + } + }); + return tempSubmitData; +}; + +/** Function to handle the date picker format on submit. */ +const handleDatePickerSubmit = (submitData) => { + Object.entries(submitData).forEach((field) => { + const fieldName = field[0]; + const fieldValue = field[1]; + dates.forEach((date) => { + if (date === fieldName) { + if (Array.isArray(fieldValue)) { + // eslint-disable-next-line prefer-destructuring + submitData[fieldName] = fieldValue[0]; + } else { + submitData[fieldName] = fieldValue; + } + const dateValue = new Date(submitData[fieldName]); + submitData[fieldValue] = dateValue.toISOString(); // Set new date and time + + if (!showPastDates.includes(fieldName) && datePassed(dateValue)) { + stopSubmit = true; + // Loop through all fields that don't allow previous dates + showPastDatesFieldErrors.forEach((dateField) => { + // Check if current field is found in the list of fields that don't allow previous dates + if (fieldName === dateField.name) { + // Add field label to list of invalid date fields + invalidDateFields.push(dateField.label); + } + }); + } + } + }); + }); +}; + +/** Function to handle the checkbox data format on submit. */ +const handleCheckboxSubmit = (submitData) => { + Object.entries(submitData).forEach((field) => { + const fieldName = field[0]; + const fieldValue = field[1]; + checkBoxes.forEach((checkbox) => { + if (checkbox === fieldName) { + if (fieldValue) { + submitData[fieldName] = 't'; + } else { + submitData[fieldName] = 'f'; + } + } + }); + }); +}; + +/** Function to handle the form data on form submit. */ +export const prepareSubmitData = (submitAction, values, setShowDateError) => { + let submitData = { action: submitAction, ...values }; + stopSubmit = false; + invalidDateFields = []; + + if (hasTime) { + submitData = handleTimePickerSubmit(submitData); + } + + if (dates.length > 0) { + handleDatePickerSubmit(submitData); + } + + setShowDateError(invalidDateFields); + + if (checkBoxes.length > 0) { + handleCheckboxSubmit(submitData); + } + + if (stopSubmit) { + return false; + } + return submitData; +}; diff --git a/app/javascript/components/service-dialog-builder/service-dialog-builder.schema.js b/app/javascript/components/service-dialog-builder/service-dialog-builder.schema.js new file mode 100644 index 00000000000..d1bd62ba1dd --- /dev/null +++ b/app/javascript/components/service-dialog-builder/service-dialog-builder.schema.js @@ -0,0 +1,33 @@ +import { componentTypes } from '@@ddf'; + +const showDateErrorFields = (fields) => { + let invalidFields; + fields.forEach((field) => { + if (invalidFields) { + invalidFields = `${invalidFields}, ${field}`; + } else { + invalidFields = field; + } + }); + return invalidFields; +}; + +const createSchema = (fields, showDateError) => ({ + fields: [ + { + component: componentTypes.TABS, + name: 'tabs', + fields, + }, + ...(showDateError.length > 0 ? [ + { + id: 'dateWarning', + component: componentTypes.PLAIN_TEXT, + name: 'dateWarning', + label: __(`Invalid date selected for ${showDateErrorFields(showDateError)}. Please select a future date.`), + }, + ] : []), + ], +}); + +export default createSchema; diff --git a/app/javascript/components/service-dialog-builder/service-dialog-refresh-button.jsx b/app/javascript/components/service-dialog-builder/service-dialog-refresh-button.jsx new file mode 100644 index 00000000000..5eedc059179 --- /dev/null +++ b/app/javascript/components/service-dialog-builder/service-dialog-refresh-button.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Button, Loading } from 'carbon-components-react'; +import { Renew16 } from '@carbon/icons-react'; +import PropTypes from 'prop-types'; + +const ServiceDialogRefreshButton = ({ onRefresh, showRefreshButton, fieldName }) => ( + <> + {showRefreshButton && ( + + + + )} + + + ); +}; + +FormTemplate.propTypes = { + formFields: PropTypes.arrayOf(PropTypes.any).isRequired, +}; + +ServiceReconfigureForm.propTypes = { + dialogLocals: PropTypes.shape({ + resourceActionId: PropTypes.string, + targetId: PropTypes.string, + }), +}; +ServiceReconfigureForm.defaultProps = { + dialogLocals: undefined, +}; + +export default ServiceReconfigureForm; diff --git a/app/javascript/oldjs/controllers/dialog_user/dialog_user_controller.js b/app/javascript/oldjs/controllers/dialog_user/dialog_user_controller.js index 7491679e41e..f761e21db6c 100644 --- a/app/javascript/oldjs/controllers/dialog_user/dialog_user_controller.js +++ b/app/javascript/oldjs/controllers/dialog_user/dialog_user_controller.js @@ -1,14 +1,14 @@ ManageIQ.angular.app.controller('dialogUserController', ['API', 'dialogFieldRefreshService', 'miqService', 'dialogUserSubmitErrorHandlerService', 'dialogId', 'apiSubmitEndpoint', 'apiAction', 'finishSubmitEndpoint', 'cancelEndpoint', 'resourceActionId', 'targetId', 'targetType', 'realTargetType', 'openUrl', '$http', '$window', 'dialogReplaceData', 'DialogData', function(API, dialogFieldRefreshService, miqService, dialogUserSubmitErrorHandlerService, dialogId, apiSubmitEndpoint, apiAction, finishSubmitEndpoint, cancelEndpoint, resourceActionId, targetId, targetType, realTargetType, openUrl, $http, $window, dialogReplaceData, DialogData) { - var vm = this; + const vm = this; vm.$onInit = function() { - var apiCall = new Promise(function(resolve) { - var url = '/api/service_dialogs/' + dialogId + - '?resource_action_id=' + resourceActionId + - '&target_id=' + targetId + - '&target_type=' + targetType; + const apiCall = new Promise((resolve) => { + const url = `/api/service_dialogs/${dialogId + }?resource_action_id=${resourceActionId + }&target_id=${targetId + }&target_type=${targetType}`; - resolve(API.get(url, {expand: 'resources', attributes: 'content'}).then(init)); + resolve(API.get(url, { expand: 'resources', attributes: 'content' }).then(init)); }); Promise.resolve(apiCall).then(miqService.refreshSelectpicker); @@ -18,15 +18,15 @@ ManageIQ.angular.app.controller('dialogUserController', ['API', 'dialogFieldRefr vm.dialog = dialog.content[0]; vm.dialogLoaded = true; - _.forEach(vm.dialog.dialog_tabs, function(tab) { - _.forEach(tab.dialog_groups, function(group) { - _.forEach(group.dialog_fields, function(field) { - const replaceField = dialogReplaceData ? JSON.parse(dialogReplaceData).find(function (replace) { return replace.name === field.name }) : false; + _.forEach(vm.dialog.dialog_tabs, (tab) => { + _.forEach(tab.dialog_groups, (group) => { + _.forEach(group.dialog_fields, (field) => { + const replaceField = dialogReplaceData ? JSON.parse(dialogReplaceData).find((replace) => replace.name === field.name) : false; if (replaceField) { field.default_value = replaceField.value; } if (field.type === 'DialogFieldDropDownList') { - _.forEach(field.values, function(value) { + _.forEach(field.values, (value) => { if (value[0] === null) { value[1] = __(value[1]); } @@ -49,12 +49,16 @@ ManageIQ.angular.app.controller('dialogUserController', ['API', 'dialogFieldRefr vm.isValid = false; function refreshField(field) { - var idList = { - dialogId: dialogId, - resourceActionId: resourceActionId, - targetId: targetId, - targetType: targetType, - realTargetType: realTargetType, + console.log('111=', field); + // API.post(field.href).then((data) => { + // console.log(data); + // }); + const idList = { + dialogId, + resourceActionId, + targetId, + targetType, + realTargetType, }; return dialogFieldRefreshService.refreshField(vm.dialogData, [field.name], vm.refreshUrl, idList); @@ -67,24 +71,24 @@ ManageIQ.angular.app.controller('dialogUserController', ['API', 'dialogFieldRefr function submitButtonClicked() { vm.dialogData.action = apiAction; - miqService.sparkleOn(); + // miqService.sparkleOn(); - var apiData = DialogData.outputConversion(vm.dialogData); + let apiData = DialogData.outputConversion(vm.dialogData); if (apiSubmitEndpoint.match(/generic_objects/)) { - apiData = {action: apiAction, parameters: _.omit(apiData, 'action')}; + apiData = { action: apiAction, parameters: _.omit(apiData, 'action') }; } else if (apiAction === 'reconfigure') { - apiData = {action: apiAction, resource: _.omit(apiData, 'action')}; + apiData = { action: apiAction, resource: _.omit(apiData, 'action') }; } - return API.post(apiSubmitEndpoint, apiData, {skipErrors: [400]}) - .then(function(response) { - + return API.post(apiSubmitEndpoint, apiData, { skipErrors: [400] }) + .then((response) => { if (vm.openUrl === 'true') { return API.wait_for_task(response.task_id) - .then(function() { - return $http.post('open_url_after_dialog', {targetId: vm.targetId, realTargetType: realTargetType}); + .then(() => { + console.log(API.wait_for_task(response.task_id)); + return $http.post('open_url_after_dialog', { targetId: vm.targetId, realTargetType }); }) - .then(function(response) { + .then((response) => { if (response.data.open_url) { $window.open(response.data.open_url); miqService.redirectBack(__('Order Request was Submitted'), 'success', finishSubmitEndpoint); @@ -93,13 +97,11 @@ ManageIQ.angular.app.controller('dialogUserController', ['API', 'dialogFieldRefr miqService.sparkleOff(); } }) - .catch(function() { - return Promise.reject({data: {error: {message: '-'.concat(__('Automate failed to obtain URL.')) }}}); - }); + .catch(() => Promise.reject({ data: { error: { message: '-'.concat(__('Automate failed to obtain URL.')) } } })); } miqService.redirectBack(__('Order Request was Submitted'), 'success', finishSubmitEndpoint); }) - .catch(function(err) { + .catch((err) => { dialogUserSubmitErrorHandlerService.handleError(err); }); } diff --git a/app/javascript/oldjs/controllers/dialog_user/dialog_user_reconfigure_controller.js b/app/javascript/oldjs/controllers/dialog_user/dialog_user_reconfigure_controller.js index 63a384a1b98..341527ec4c1 100644 --- a/app/javascript/oldjs/controllers/dialog_user/dialog_user_reconfigure_controller.js +++ b/app/javascript/oldjs/controllers/dialog_user/dialog_user_reconfigure_controller.js @@ -1,10 +1,10 @@ ManageIQ.angular.app.controller('dialogUserReconfigureController', ['API', 'dialogFieldRefreshService', 'miqService', 'dialogUserSubmitErrorHandlerService', 'resourceActionId', 'targetId', 'DialogData', function(API, dialogFieldRefreshService, miqService, dialogUserSubmitErrorHandlerService, resourceActionId, targetId, DialogData) { - var vm = this; + const vm = this; vm.$onInit = function() { - var apiCall = new Promise(function(resolve) { - var url = '/api/services/' + targetId + - '?attributes=reconfigure_dialog'; + const apiCall = new Promise((resolve) => { + const url = `/api/services/${targetId + }?attributes=reconfigure_dialog`; resolve(API.get(url).then(init)); }); @@ -28,13 +28,12 @@ ManageIQ.angular.app.controller('dialogUserReconfigureController', ['API', 'dial vm.isValid = false; function refreshField(field) { - var idList = { + const idList = { dialogId: vm.dialogId, - resourceActionId: resourceActionId, - targetId: targetId, + resourceActionId, + targetId, targetType: 'service', }; - return dialogFieldRefreshService.refreshField(vm.dialogData, [field.name], vm.refreshUrl, idList); } @@ -46,17 +45,15 @@ ManageIQ.angular.app.controller('dialogUserReconfigureController', ['API', 'dial function submitButtonClicked() { miqService.sparkleOn(); - var apiData = { + const apiData = { action: 'reconfigure', resource: _.omit(DialogData.outputConversion(vm.dialogData), 'action'), }; - var apiSubmitEndpoint = '/api/services/' + targetId; + const apiSubmitEndpoint = `/api/services/${targetId}`; - return API.post(apiSubmitEndpoint, apiData, {skipErrors: [400]}).then(function() { + return API.post(apiSubmitEndpoint, apiData, { skipErrors: [400] }).then(() => { miqService.redirectBack(__('Order Request was Submitted'), 'info', '/service'); - }).catch(function(err) { - return Promise.reject(dialogUserSubmitErrorHandlerService.handleError(err)); - }); + }).catch((err) => Promise.reject(dialogUserSubmitErrorHandlerService.handleError(err))); } function cancelClicked(_event) { diff --git a/app/javascript/oldjs/services/dialog_field_refresh_service.js b/app/javascript/oldjs/services/dialog_field_refresh_service.js index 1c125882ddf..1aafcd636d9 100644 --- a/app/javascript/oldjs/services/dialog_field_refresh_service.js +++ b/app/javascript/oldjs/services/dialog_field_refresh_service.js @@ -16,8 +16,11 @@ ManageIQ.angular.app.service('dialogFieldRefreshService', ['API', 'DialogData', }, }; + console.log(data); + return API.post(url + idList.dialogId, angular.toJson(data)) .then(function(response) { + console.log('333=', response); // FIXME: API requests don't actually count towards $.active if ($.active < 1) { self.areFieldsBeingRefreshed = false; diff --git a/app/javascript/packs/component-definitions-common.js b/app/javascript/packs/component-definitions-common.js index 610d3b33e65..bb1effc3e12 100644 --- a/app/javascript/packs/component-definitions-common.js +++ b/app/javascript/packs/component-definitions-common.js @@ -81,6 +81,8 @@ import OptimizationList from '../components/data-tables/optimization/optimizatio import OpsTenantForm from '../components/ops-tenant-form/ops-tenant-form'; import OrcherstrationTemplateForm from '../components/orchestration-template/orcherstration-template-form'; import ProvGrid from '../components/prov-grid'; +import OrderServiceForm from '../components/order-service-form'; +import OrderServiceRefreshButton from '../components/service-dialog-builder/service-dialog-refresh-button'; import PxeImageForm from '../components/pxe-image-type-form'; import PxeCustomizationTemplateForm from '../components/pxe-customization-template-form'; import PxeIsoDatastoreForm from '../components/pxe-iso-datastore-form'; @@ -102,11 +104,13 @@ import ReportList from '../components/data-tables/reports/ReportList'; import ReportDataTable from '../components/data-tables/report-data-table/report-data-table'; import RetirementForm from '../components/retirement-form'; import RoleList from '../components/data-tables/role-list'; +import RequestDialogOptions from '../components/request-dialog-options'; import RequestsTable from '../components/data-tables/requests-table'; import RequestWorkflowStatus from '../components/request-workflow-status'; import RoutersForm from '../components/routers-form'; import ServiceDialogFromForm from '../components/service-dialog-from-form/service-dialog-from'; import ServiceDetailStdout from '../components/service-detail-stdout'; +import ServiceReconfigureForm from '../components/service-reconfigure-form'; import SettingsTimeProfileForm from '../components/settings-time-profile-form'; import SettingsCategoryForm from '../components/settings-category-form'; import SettingsZone from '../components/settings-zone'; @@ -251,6 +255,8 @@ ManageIQ.component.addReact('ObjectTypesList', ObjectTypesList); ManageIQ.component.addReact('OpsTenantForm', OpsTenantForm); ManageIQ.component.addReact('OptimizationList', OptimizationList); ManageIQ.component.addReact('OrcherstrationTemplateForm', OrcherstrationTemplateForm); +ManageIQ.component.addReact('OrderServiceForm', OrderServiceForm); +ManageIQ.component.addReact('OrderServiceRefreshButton', OrderServiceRefreshButton); ManageIQ.component.addReact('PxeImageForm', PxeImageForm); ManageIQ.component.addReact('PxeCustomizationTemplateForm', PxeCustomizationTemplateForm); ManageIQ.component.addReact('PxeIsoDatastoreForm', PxeIsoDatastoreForm); @@ -272,6 +278,7 @@ ManageIQ.component.addReact('ReportDataTable', ReportDataTable); ManageIQ.component.addReact('ReportList', ReportList); ManageIQ.component.addReact('RetirementForm', RetirementForm); ManageIQ.component.addReact('RoleList', RoleList); +ManageIQ.component.addReact('RequestDialogOptions', RequestDialogOptions); ManageIQ.component.addReact('RequestsTable', RequestsTable); ManageIQ.component.addReact('RequestWorkflowStatus', RequestWorkflowStatus); ManageIQ.component.addReact('RoutersForm', RoutersForm); @@ -280,6 +287,7 @@ ManageIQ.component.addReact('ServiceDialogFromForm', ServiceDialogFromForm); ManageIQ.component.addReact('ServiceDetailStdout', ServiceDetailStdout); ManageIQ.component.addReact('SettingsTasksForm', SettingsTasksForm); ManageIQ.component.addReact('EditServiceForm', EditServiceForm); +ManageIQ.component.addReact('ServiceReconfigureForm', ServiceReconfigureForm); ManageIQ.component.addReact('ServiceRequestDefault', ServiceRequestDefault); ManageIQ.component.addReact('SetOwnershipForm', SetOwnershipForm); ManageIQ.component.addReact('ServersDataChart', ServersDataChart); diff --git a/app/javascript/spec/order-service-form/__snapshots__/order-service-form.spec.js.snap b/app/javascript/spec/order-service-form/__snapshots__/order-service-form.spec.js.snap new file mode 100644 index 00000000000..8d6fca2785b --- /dev/null +++ b/app/javascript/spec/order-service-form/__snapshots__/order-service-form.spec.js.snap @@ -0,0 +1,308 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Order Service Form Component should edit template with a parent and children 1`] = ` + + + +
+ + + + Active loading indicator + + + + +
+
+
+
+`; + +exports[`Order Service Form Component should edit template with no parent and children 1`] = ` + + + +
+ + + + Active loading indicator + + + + +
+
+
+
+`; + +exports[`Order Service Form Component should edit vm with a parent and children 1`] = ` + + + +
+ + + + Active loading indicator + + + + +
+
+
+
+`; + +exports[`Order Service Form Component should edit vm with no parent or children 1`] = ` + + + +
+ + + + Active loading indicator + + + + +
+
+
+
+`; + +exports[`Order Service Form Component should render order service form 1`] = ` + + + +`; diff --git a/app/javascript/spec/order-service-form/order-service-form.spec.js b/app/javascript/spec/order-service-form/order-service-form.spec.js new file mode 100644 index 00000000000..3bcc1fbdb58 --- /dev/null +++ b/app/javascript/spec/order-service-form/order-service-form.spec.js @@ -0,0 +1,304 @@ +import React from 'react'; +import toJson from 'enzyme-to-json'; +import fetchMock from 'fetch-mock'; +import { mount } from '../helpers/mountForm'; +import OrderServiceForm from '../../components/order-service-form'; +import VmEditForm from '../../components/vm-edit-form'; + +require('../helpers/miqSparkle.js'); +require('../helpers/miqAjaxButton.js'); + +describe('Order Service Form Component', () => { + let submitSpy; + const dialogId = 10; + const resourceActionId = 2018; + const targetId = 14; + const targetType = 'service_template'; + + const dialogFields = { + + }; + + const vmInitialValues = { + ancestry: '4698', + id: '4671', + name: '2_vcr_liberty_keystone_v3', + custom_attributes: { name: 'custom_1', value: 'test' }, + description: 'test1', + template: false, + parent_resource: { + id: '4698', + name: 'billy_cloudformator', + location: '5b0a20b7-3583-49fc-a1e1-e64e528476d6.ovf', + }, + child_resources: [ + { + ancestry: '4698/4671', + id: '4673', + name: '4_vcr_icehouse', + location: '8fdc4382-fa5e-4c76-b1bf-d961ad3c7813.ovf', + }, + { + ancestry: '4698/4671', + id: '4672', + name: '3_vcr_kilo', + location: '7404871d-ef18-4aee-86a4-6c9e1807867b.ovf', + }, + ], + }; + const templateInitialValues = { + ancestry: '1223', + id: '2686', + name: 'amaya-insights-el7.0', + custom_attributes: { name: 'custom_1', value: 'test' }, + description: 'test1', + template: true, + parent_resource: { + id: '1223', + name: 'cfme029', + location: '915334fb-d454-43ba-a3cc-1a52b3f3b98d.ovf', + }, + child_resources: [ + { + ancestry: '1223/2686', + id: '2983', + name: '3.9ocp', + location: 'a1d3bb38-5295-4852-bbe7-93481350bc93.ovf', + }, + { + ancestry: '1223/2686', + id: '2600', + name: 'apitest1', + location: '9e27b4b6-4e32-412c-b38c-84466a4142cd.ovf', + }, + ], + }; + beforeEach(() => { + submitSpy = jest.spyOn(window, 'miqAjaxButton'); + }); + afterEach(() => { + fetchMock.reset(); + fetchMock.restore(); + submitSpy.mockRestore(); + }); + it('should render order service form', () => { + const initialData = { + dialogId, + resourceActionId, + targetId, + targetType, + apiSubmitEndpoint: "/api/service_catalogs/9/service_templates/14", + apiAction: 'order', + openUrl: false, + realTargetType: "ServiceTemplate", + finishSubmitEndpoint: "/miq_request/show_list", + }; + const wrapper = mount(); + fetchMock.getOnce( + `/api/service_dialogs/${dialogId}?resource_action_id=${resourceActionId}&target_id=${targetId}&target_type=${targetType}`, + dialogFields + ); + expect(toJson(wrapper)).toMatchSnapshot(); + }); + it('should edit vm with a parent and children', async(done) => { + const parentVMOptions = { + resources: [ + { + id: '4698', + name: 'billy_cloudformator', + location: '5b0a20b7-3583-49fc-a1e1-e64e528476d6.ovf', + }, + { + id: '4680', + name: 'ag_destroy_test', + location: 'ed24a738-41e6-40c9-9adc-606e6f66f2ba.ovf', + }, + { + id: '4734', + name: 'billy_cloudformator', + location: '5b0a20b7-3583-49fc-a1e1-e64e528476d6.ovf', + }], + }; + + const parentTemplateOptions = { + resources: [ + { + id: '4668', + name: 'win2012-temp', + location: '73c9b538-a84c-462f-a7f9-2c08fc3e212a.ovf', + }], + }; + + const data = { + action: 'edit', + resource: { + custom_1: 'test', + description: 'test description', + parent_resource: { href: `/api/vms/4734` }, + child_resources: [ + { href: `/api/vms/4672` }, + { href: `/api/vms/4673` }, + { href: `/api/templates/4668` }, + ], + }, + }; + + fetchMock.getOnce(`/api/vms/?filter[]=ems_id=56&expand=resources`, parentVMOptions); + fetchMock.getOnce(`/api/templates/?filter[]=ems_id=56&expand=resources`, parentTemplateOptions); + fetchMock.getOnce(`/api/vms/4671?attributes=child_resources,parent_resource,custom_attributes`, vmInitialValues); + fetchMock.postOnce('/api/vms/4671', data); + const wrapper = mount(); + expect(toJson(wrapper)).toMatchSnapshot(); + done(); + }); + it('should edit vm with no parent or children', async(done) => { + const parentVMOptions = { + resources: [ + { + id: '4698', + name: 'billy_cloudformator', + location: '5b0a20b7-3583-49fc-a1e1-e64e528476d6.ovf', + }, + { + id: '4680', + name: 'ag_destroy_test', + location: 'ed24a738-41e6-40c9-9adc-606e6f66f2ba.ovf', + }, + { + id: '4734', + name: 'billy_cloudformator', + location: '5b0a20b7-3583-49fc-a1e1-e64e528476d6.ovf', + }, + ], + }; + + const parentTemplateOptions = { + resources: [ + { + id: '4668', + name: 'win2012-temp', + location: '73c9b538-a84c-462f-a7f9-2c08fc3e212a.ovf', + }, + ], + }; + + const data = { + action: 'edit', + resource: { + custom_1: 'test', + description: 'test description', + parent_resource: null, + child_resources: [], + }, + }; + + fetchMock.getOnce(`/api/vms/?filter[]=ems_id=56&expand=resources`, parentVMOptions); + fetchMock.getOnce(`/api/templates/?filter[]=ems_id=56&expand=resources`, parentTemplateOptions); + fetchMock.getOnce(`/api/vms/4671?attributes=child_resources,parent_resource,custom_attributes`, vmInitialValues); + fetchMock.postOnce('/api/vms/4671', data); + const wrapper = mount(); + expect(toJson(wrapper)).toMatchSnapshot(); + done(); + }); + it('should edit template with a parent and children', async(done) => { + const parentVMOptions = { + resources: [ + { + id: '1223', + name: 'cfme029', + location: '915334fb-d454-43ba-a3cc-1a52b3f3b98d.ovf', + }, + { + id: '1224', + name: 'cfme086', + location: '47014391-b41c-4ab0-ade4-b807c5387d7b.ovf', + }, + ], + }; + + const parentTemplateOptions = { + resources: [ + { + id: '1270', + name: 'rhel-guest-image-7.2', + location: '959d0dfb-e06e-4269-934f-8d346eaa7a42.ovf', + }, + { + id: '1268', + name: 'RHEL7_Base', + location: '76e0d3aa-281c-4ec4-b6a7-947e6ffba448.ovf', + }, + ], + }; + + const data = { + action: 'edit', + resource: { + custom_1: 'test', + description: 'test description', + parent_resource: { href: `/api/vms/1224` }, + child_resources: [ + { href: `/api/templates/2983` }, + { href: `/api/templates/2600` }, + { href: `/api/templates/1270` }, + ], + }, + }; + fetchMock.getOnce('/api/vms/?filter[]=ems_id=22&expand=resources', parentVMOptions); + fetchMock.getOnce('/api/templates/?filter[]=ems_id=22&expand=resources', parentTemplateOptions); + fetchMock.getOnce('/api/templates/2686?attributes=child_resources,parent_resource,custom_attributes', templateInitialValues); + fetchMock.postOnce('/api/templates/2686', data); + const wrapper = mount(); + expect(toJson(wrapper)).toMatchSnapshot(); + done(); + }); + it('should edit template with no parent and children', async(done) => { + const parentVMOptions = { + resources: [ + { + id: '1223', + name: 'cfme029', + location: '915334fb-d454-43ba-a3cc-1a52b3f3b98d.ovf', + }, + { + id: '1224', + name: 'cfme086', + location: '47014391-b41c-4ab0-ade4-b807c5387d7b.ovf', + }, + ], + }; + + const parentTemplateOptions = { + resources: [ + { + id: '1270', + name: 'rhel-guest-image-7.2', + location: '959d0dfb-e06e-4269-934f-8d346eaa7a42.ovf', + }, + { + id: '1268', + name: 'RHEL7_Base', + location: '76e0d3aa-281c-4ec4-b6a7-947e6ffba448.ovf', + }, + ], + }; + + const data = { + action: 'edit', + resource: { + custom_1: 'test', + description: 'test description', + parent_resource: null, + child_resources: [], + }, + }; + + fetchMock.getOnce('/api/vms/?filter[]=ems_id=22&expand=resources', parentVMOptions); + fetchMock.getOnce('/api/templates/?filter[]=ems_id=22&expand=resources', parentTemplateOptions); + fetchMock.getOnce('/api/templates/2686?attributes=child_resources,parent_resource,custom_attributes', templateInitialValues); + fetchMock.postOnce('/api/templates/2686', data); + const wrapper = mount(); + expect(toJson(wrapper)).toMatchSnapshot(); + done(); + }); +}); diff --git a/app/javascript/spec/request-dialog-options/__snapshots__/request-dialog-options.spec.js.snap b/app/javascript/spec/request-dialog-options/__snapshots__/request-dialog-options.spec.js.snap new file mode 100644 index 00000000000..bf17b4ca8cf --- /dev/null +++ b/app/javascript/spec/request-dialog-options/__snapshots__/request-dialog-options.spec.js.snap @@ -0,0 +1,5566 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RequestDialogOptions component dialog options should contain 1 checkbox 1`] = ` + + +
+ +
    + +
  • + +
  • +
    + +
  • + +
  • +
    +
+ +
+ + +
+
+`; + +exports[`RequestDialogOptions component dialog options should contain total 2 tabs 1`] = ` + + + + + + + + + + +`; + +exports[`RequestDialogOptions component dialog options should contain total 4 sections 1`] = ` + + +
+ +
    + +
  • + +
  • +
    + +
  • + +
  • +
    +
+ +
+ + +
+
+`; + +exports[`RequestDialogOptions component dialog options should contain total 13 rows 1`] = ` + + +
+ +
    + +
  • + +
  • +
    + +
  • + +
  • +
    +
+ +
+ + +
+
+`; diff --git a/app/javascript/spec/request-dialog-options/data.js b/app/javascript/spec/request-dialog-options/data.js new file mode 100644 index 00000000000..c7a1f9b9b59 --- /dev/null +++ b/app/javascript/spec/request-dialog-options/data.js @@ -0,0 +1,56 @@ +export const data = [ + { + label: _('Tab 001'), + content: [ + { + mode: 'miq_summary request_dialog_options title-001', + title: 'section-title-001', + rows: [ + { cells: { label: _('Field 1'), value: _('Value 1') } }, + { cells: { label: _('Field 2'), value: _('Value 2') } }, + { cells: { label: _('Field 3'), value: _('Value 3') } }, + ], + }, + { + mode: 'miq_summary request_dialog_options title-002', + title: 'section-title-002', + rows: [ + { cells: { label: _('Field 1'), value: _('Value 1') } }, + { cells: { label: _('Field 2'), value: _('Value 2') } }, + { cells: { label: _('Field 3'), value: _('Value 3') } }, + { + cells: { + label: _('Field 4'), + value: { + input: 'checkbox', checked: true, disabled: true, label: '', + }, + }, + }, + ], + }, + ], + }, + { + label: _('Tab 002'), + content: [ + { + mode: 'miq_summary request_dialog_options title-001', + title: 'section-title-001', + rows: [ + { cells: { label: _('Field 1'), value: _('Value 1') } }, + { cells: { label: _('Field 2'), value: _('Value 2') } }, + { cells: { label: _('Field 3'), value: _('Value 3') } }, + ], + }, + { + mode: 'miq_summary request_dialog_options title-002', + title: 'section-title-002', + rows: [ + { cells: { label: _('Field 1'), value: _('Value 1') } }, + { cells: { label: _('Field 2'), value: _('Value 2') } }, + { cells: { label: _('Field 3'), value: _('Value 3') } }, + ], + }, + ], + }, +]; diff --git a/app/javascript/spec/request-dialog-options/request-dialog-options.spec.js b/app/javascript/spec/request-dialog-options/request-dialog-options.spec.js new file mode 100644 index 00000000000..317b1381dd9 --- /dev/null +++ b/app/javascript/spec/request-dialog-options/request-dialog-options.spec.js @@ -0,0 +1,38 @@ +import React from 'react'; +import toJson from 'enzyme-to-json'; +import { mount, shallow } from 'enzyme'; +import RequestDialogOptions from '../../components/request-dialog-options'; +import { data } from './data'; + +describe('RequestDialogOptions component', () => { + it('dialog options should contain total 2 tabs', () => { + const wrapper = shallow(); + expect(toJson(wrapper)).toMatchSnapshot(); + expect(wrapper.find('.dialog-option-tab')).toHaveLength(data.length); + }); + + it('dialog options should contain total 4 sections', () => { + const wrapper = mount(); + const length = data + .map((item) => item.content.length) // Extract lengths of 'content' + .reduce((total, length) => total + length, 0); // Calculate the sum + expect(wrapper.find('.bx--tab-content ul')).toHaveLength(length); + expect(toJson(wrapper)).toMatchSnapshot(); + }); + + it('dialog options should contain 1 checkbox', () => { + const wrapper = mount(); + expect(wrapper.exists('input[type="checkbox"]')).toBe(true); + expect(toJson(wrapper)).toMatchSnapshot(); + }); + + it('dialog options should contain total 13 rows', () => { + const wrapper = mount(); + const length = data.reduce((sum, tab) => { + const tabContentRows = tab.content.flatMap((section) => section.rows); + return sum + tabContentRows.length; + }, 0); + expect(wrapper.find('.bx--structured-list-row')).toHaveLength(length); + expect(toJson(wrapper)).toMatchSnapshot(); + }); +}); diff --git a/app/stylesheet/ddf_override.scss b/app/stylesheet/ddf_override.scss index 8c6beaf7e4f..b5b6808e5bc 100644 --- a/app/stylesheet/ddf_override.scss +++ b/app/stylesheet/ddf_override.scss @@ -295,7 +295,8 @@ } .loadingSpinner { - margin-left: 15rem; + display: flex; + justify-content: center; } #provider-modal { @@ -389,6 +390,7 @@ width: fit-content; } + .miq-fieldset { border: 0; margin: 0; @@ -417,3 +419,36 @@ flex-grow: 1; } } + +#order-service-form { + + .order-form-row { + display: flex; + flex-direction: row; + gap: 10px; + align-items: flex-start; + margin-bottom: 2rem; + + div:first-child { + flex-grow: 1; + } + + .refresh-button { + margin-top: 20px; + margin-right: 0; + } + + } + .bx--btn--primary { + margin-right: 10px; + } + + .refreshSpinner { + margin-top: 35px; + display: none; + } + + .bx--list-box__wrapper { + margin-bottom: 0; + } +} diff --git a/app/views/miq_request/_request_dialog_details.html.haml b/app/views/miq_request/_request_dialog_details.html.haml index 8c437131ed0..94745088bcf 100644 --- a/app/views/miq_request/_request_dialog_details.html.haml +++ b/app/views/miq_request/_request_dialog_details.html.haml @@ -28,7 +28,25 @@ = h(field.values.detect { |k, _v| k == wf.value(field.name) }.try(:last) || wf.value(field.name)) - when "DialogFieldDropDownList" - = h(field.value.blank? ? _("") : field.value.to_s.gsub("\x1F", ", ")) + - if field.value.class == Integer + - field.values.each do |option| + - if field.value.to_s == option[0].to_s + - if option[1] + = h(option[1] || _("")) +
+ - elsif field.value.nil? + - field.values.each do |option| + - if field.value.to_s == option[0].to_s + - if option[1] + = h(option[1] || _("")) +
+ - else + - field.value.split("\u001F").each do |value| + - field.values.each do |option| + - if value.to_s == option[0].to_s + - if option[1] + = h(option[1] || _("")) +
- when 'DialogFieldTagControl' - value = wf.value(field.name) || '' # it returns in format Clasification::id diff --git a/app/views/miq_request/_service_reconfigure_show.html.haml b/app/views/miq_request/_service_reconfigure_show.html.haml index 24e9a51841a..63e188f2ea1 100644 --- a/app/views/miq_request/_service_reconfigure_show.html.haml +++ b/app/views/miq_request/_service_reconfigure_show.html.haml @@ -4,11 +4,8 @@ - ra = st.resource_actions.find_by_action('Reconfigure') if st - if ra && ra.dialog - values = @miq_request.options[:dialog] - - opts = {} - %fieldset - %h3 - = _("Dialog Options") - .row - .col-md-12.col-lg-12 - = render :partial => "shared/dialogs/dialog_provision", - :locals => {:wf => ResourceActionWorkflow.new(values, current_user, ra, opts)} + - opts = {:reconfigure => true} + - wf = ResourceActionWorkflow.new(values, current_user, ra, opts) + %h3 + = _("Dialog Options") + = request_dialog_options(wf) diff --git a/app/views/miq_request/_st_prov_show.html.haml b/app/views/miq_request/_st_prov_show.html.haml index b5bda77229f..7ba2141fac5 100644 --- a/app/views/miq_request/_st_prov_show.html.haml +++ b/app/views/miq_request/_st_prov_show.html.haml @@ -4,35 +4,7 @@ - if ra && ra.dialog - values = @miq_request.options[:dialog] - opts = {:display_view_only => true} - - wf = ResourceActionWorkflow.new(values, current_user, ra, opts) - %fieldset - %h3 - = _("Dialog Options") - .row - .col-md-12.col-lg-12 - #dialog_tabs - %ul.nav.nav-tabs{'role' => 'tablist'} - - wf.dialog.dialog_tabs.each_with_index do |tab, tab_index| - - options = tab_index == 0 ? {:class => "active"} : {} - = miq_tab_header(tab.id, nil, options) do - = _(tab.label) - .tab-content - - wf.dialog.dialog_tabs.each_with_index do |tab, tab_index| - - options = tab_index == 0 ? {:class => "active"} : {} - = miq_tab_content(tab.id, nil, options) do - - tab.dialog_groups.each do |group| - %div{:id => "group_#{group.id}_div"} - %h3{:title => "#{group.description}"} - = _(group.label) - - unless group.dialog_fields.empty? - .form-horizontal - - group.dialog_fields.each do |field| - = render :partial => "miq_request/request_dialog_details", - :locals => {:wf => wf, :field => field} - %hr - - - record_ids = request_task_configuration_script_ids(@miq_request) - - if record_ids.any? %h3 - = _("Workflow States") - = react('RequestWorkflowStatus', {:ids => record_ids}) + = _("Dialog Options") + - wf = ResourceActionWorkflow.new(values, current_user, ra, opts) + = request_dialog_options(wf) diff --git a/app/views/service/service_reconfigure.html.haml b/app/views/service/service_reconfigure.html.haml new file mode 100644 index 00000000000..46641f3d1f6 --- /dev/null +++ b/app/views/service/service_reconfigure.html.haml @@ -0,0 +1,2 @@ += react 'ServiceReconfigureForm', :dialogLocals => { :resourceActionId => @dialog_locals[:resource_action_id], :targetId => @dialog_locals[:target_id] } += render :partial => "shared/dialogs/reconfigure_dialog" diff --git a/app/views/shared/dialogs/_dialog_user.html.haml b/app/views/shared/dialogs/_dialog_user.html.haml index e0f222167b4..a1ee30d97a3 100644 --- a/app/views/shared/dialogs/_dialog_user.html.haml +++ b/app/views/shared/dialogs/_dialog_user.html.haml @@ -1,3 +1,19 @@ +- if @dialog_locals + - dialog = order_service_data(@dialog_locals) + - api_submit_endpoint ||= dialog[:apiSubmitEndpoint] + - api_action ||= dialog[:apiAction] + - cancel_endpoint ||= dialog[:cancelEndPoint] + - dialog_id ||= dialog[:dialogId] + - finish_submit_endpoint ||= dialog[:finishSubmitEndpoint] + - open_url ||= dialog[:openUrl] + - resource_action_id ||= dialog[:resourceActionId] + - real_target_type ||= dialog[:realTargetType] + - target_id ||= dialog[:targetId] + - target_type ||= dialog[:targetType] + + = react('OrderServiceForm', :initialData => dialog) + + .row.wrapper{"ng-controller" => "dialogUserController as vm"} .spinner{'ng-show' => "!vm.dialogLoaded"} diff --git a/app/views/shared/dialogs/_reconfigure_dialog.html.haml b/app/views/shared/dialogs/_reconfigure_dialog.html.haml index 386b7172941..bfb4aee24e4 100644 --- a/app/views/shared/dialogs/_reconfigure_dialog.html.haml +++ b/app/views/shared/dialogs/_reconfigure_dialog.html.haml @@ -20,6 +20,6 @@ 'on-click' => "vm.cancelClicked($event)"} :javascript - ManageIQ.angular.app.value('resourceActionId', '#{resource_action_id}'); - ManageIQ.angular.app.value('targetId', '#{target_id}'); + ManageIQ.angular.app.value('resourceActionId', '#{@dialog_locals[:resource_action_id]}'); + ManageIQ.angular.app.value('targetId', '#{@dialog_locals[:target_id]}'); miq_bootstrap('.wrapper'); diff --git a/config/routes.rb b/config/routes.rb index 3ea0c966987..cd31dcfc64d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2777,6 +2777,7 @@ :get => %w( dialog_load download_data + service_reconfigure reconfigure_form_fields retire button diff --git a/spec/views/miq_request/_request_dialog_details.html.haml_spec.rb b/spec/views/miq_request/_request_dialog_details.html.haml_spec.rb deleted file mode 100644 index 34daa591fda..00000000000 --- a/spec/views/miq_request/_request_dialog_details.html.haml_spec.rb +++ /dev/null @@ -1,82 +0,0 @@ -# rubocop:disable Style/OpenStructUse -describe 'miq_request/_request_dialog_details.html.haml' do - let(:wf) { FactoryBot.create(:miq_provision_virt_workflow) } - let(:dialog) do - FactoryBot.create(:miq_dialog, :name => "vm", :description => "test", :content => { - :dialogs => { - :customize => { - :description => "Customize", - :fields => { - :multi_select => { - :id => 1, - :visible => true, - :label => 'Select Box', - :name => 'select1', - :value => '1, 2', - :type => 'DialogFieldDropDownList' - }, - :integer => { - :id => 1, - :visible => true, - :label => 'Select Integer', - :name => 'select2', - :value => 100, - :type => 'DialogFieldDropDownList' - }, - :string => { - :id => 1, - :visible => true, - :label => 'Select String', - :name => 'select3', - :value => 'Multiverse of madness', - :type => 'DialogFieldDropDownList' - }, - :none => { - :id => 1, - :visible => true, - :label => 'Select None', - :name => 'select4', - :value => '', - :type => 'DialogFieldDropDownList' - } - } - } - } - }) - end - - context 'render request dialog details' do - before do - wf.dialogs = dialog - end - - it 'page should display the multi select' do - field = OpenStruct.new(dialog.content[:dialogs][:customize][:fields][:multi_select]) - render :partial => 'miq_request/request_dialog_details', :locals => {:wf => wf, :field => field} - expect(response.body).to include('Select Box') - expect(response.body).to include('1, 2') - end - - it 'page should display the integer value' do - field = OpenStruct.new(dialog.content[:dialogs][:customize][:fields][:integer]) - render :partial => 'miq_request/request_dialog_details', :locals => {:wf => wf, :field => field} - expect(response.body).to include('Select Integer') - expect(response.body).to include('100') - end - - it 'page should display the string value' do - field = OpenStruct.new(dialog.content[:dialogs][:customize][:fields][:string]) - render :partial => 'miq_request/request_dialog_details', :locals => {:wf => wf, :field => field} - expect(response.body).to include('Select String') - expect(response.body).to include('Multiverse of madness') - end - - it 'page should display the blank value as none' do - field = OpenStruct.new(dialog.content[:dialogs][:customize][:fields][:none]) - render :partial => 'miq_request/request_dialog_details', :locals => {:wf => wf, :field => field} - expect(response.body).to include('Select None') - expect(response.body).to include('<None>') - end - end -end -# rubocop:enable Style/OpenStructUse