diff --git a/app/controllers/catalog_controller.rb b/app/controllers/catalog_controller.rb index 6540d534df1..3e41e5b28e7 100644 --- a/app/controllers/catalog_controller.rb +++ b/app/controllers/catalog_controller.rb @@ -743,6 +743,7 @@ def svc_catalog_provision ra, st, svc_catalog_provision_finish_submit_endpoint ) @in_a_form = true + @dialog_locals = options[:dialog_locals] 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/helpers/catalog_helper.rb b/app/helpers/catalog_helper.rb index 6d665742e56..b0fbbfd99b6 100644 --- a/app/helpers/catalog_helper.rb +++ b/app/helpers/catalog_helper.rb @@ -3,6 +3,7 @@ module CatalogHelper include RequestInfoHelper include Mixins::AutomationMixin include OrchestrationTemplateHelper + include OrderServiceHelper def miq_catalog_resource(resources) headers = ["", _("Name"), _("Description"), _("Action Order"), _("Provision Order"), _("Action Start"), _("Action Stop"), _("Delay (mins) Start"), _("Delay (mins) Stop")] diff --git a/app/helpers/miq_request_helper.rb b/app/helpers/miq_request_helper.rb index 418ebf678fc..93366d994bb 100644 --- a/app/helpers/miq_request_helper.rb +++ b/app/helpers/miq_request_helper.rb @@ -1,12 +1,54 @@ +require 'byebug' module MiqRequestHelper include RequestInfoHelper include RequestDetailsHelper + include OrderServiceHelper def row_data(label, value) {:cells => {:label => label, :value => value}} end def request_task_configuration_script_ids(miq_request) - miq_request.miq_request_tasks.map { |task| task.options&.dig(:configuration_script_id) }.compact + miq_request.miq_request_tasks.filter_map { |task| task.options&.dig(:configuration_script_id) } + end + + def select_box_options(options) + options.map do |item| + if /Classification::(\d+)/.match?(item) + classification_id = item.match(/Classification::(\d+)/)[1] + classification = Classification.find_by(:id => classification_id) + if classification + {:label => classification.description, :value => classification.id.to_s} + end + else + classification = Classification.find_by(:id => item) + if classification + {:label => classification.description, :value => classification.id.to_s} + end + end + end + end + + def dialog_field_values(dialog, wf) + + transformed_data = dialog.transform_keys { |key| key.sub('Array::', '') } + transformed_data.transform_values do |value| + if value.to_s.include?("\u001F") + select_box_options(value.split("\u001F")) + elsif value.to_s.include?("::") + model, id = value.split("::") + record = model.constantize.find_by(:id => id) + record ? [{:label => record.description, :value => record.id}] : value + else + value + end + end + end + + def service_request_data(request_options, wf) + { + :dialogId => request_options[:workflow_settings][:dialog_id], + :requestDialogOptions => dialog_field_values(request_options[:dialog], wf), + } end end diff --git a/app/helpers/order_service_helper.rb b/app/helpers/order_service_helper.rb new file mode 100644 index 00000000000..121e0b80a2f --- /dev/null +++ b/app/helpers/order_service_helper.rb @@ -0,0 +1,20 @@ +module OrderServiceHelper + def order_service_data(dialog) + { + :dialogId => dialog[:dialog_id], + :params => { + :resourceActionId => dialog[:resource_action_id], + :targetId => dialog[:target_id], + :targetType => dialog[:target_type], + :realTargetType => dialog[:real_target_type], + }, + :urls => { + :apiSubmitEndpoint => dialog[:api_submit_endpoint], + :apiAction => dialog[:api_action], + :cancelEndPoint => dialog[:cancel_endpoint], + :finishSubmitEndpoint => dialog[:finish_submit_endpoint], + :openUrl => dialog[:open_url], + } + } + end +end diff --git a/app/javascript/components/service/DialogFields.jsx b/app/javascript/components/service/DialogFields.jsx new file mode 100644 index 00000000000..a301fae4130 --- /dev/null +++ b/app/javascript/components/service/DialogFields.jsx @@ -0,0 +1,77 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { DIALOG_FIELD_TYPES } from './constants'; +import CheckboxField from './dialogFieldItems/CheckboxField'; +import DateField from './dialogFieldItems/DateField'; +import DateTimeField from './dialogFieldItems/DateTimeField'; +import DropDownField from './dialogFieldItems/DropDownField'; +import RadioField from './dialogFieldItems/RadioField'; +import RefreshField from './RefreshField'; +import ServiceContext from './ServiceContext'; +import TagField from './dialogFieldItems/TagField'; +import TextInputField from './dialogFieldItems/TextInputField'; +import TextAreaField from './dialogFieldItems/TextAreaField'; + +/** Function to render fields based on type */ +const renderFieldContent = (field) => { + switch (field.type) { + case DIALOG_FIELD_TYPES.checkBox: + return ; + case DIALOG_FIELD_TYPES.date: + return ; + case DIALOG_FIELD_TYPES.dateTime: + return ; + case DIALOG_FIELD_TYPES.dropDown: + return ; + case DIALOG_FIELD_TYPES.radio: + return ; + case DIALOG_FIELD_TYPES.tag: + return ; + case DIALOG_FIELD_TYPES.textBox: + return ; + case DIALOG_FIELD_TYPES.textArea: + return ; + default: + return <>{__('Field not supported')}; + } +}; + +/** Function to render a field. */ +const renderFieldItem = (field, data) => { + const isRefreshing = data.fieldsToRefresh.includes(field.name); + return ( +
+
+ { + renderFieldContent(field) + } +
+ +
+ ); +}; + +/** Component to render the Fields in the Service/DialogTabs/DialogGroups component */ +const DialogFields = ({ dialogFields }) => { + const { data } = useContext(ServiceContext); + return ( + <> + { + dialogFields.map((field) => ( + field.visible ? renderFieldItem(field, data) : + )) + } + + ); +}; + +DialogFields.propTypes = { + dialogFields: PropTypes.arrayOf(PropTypes.any).isRequired, +}; + +export default DialogFields; diff --git a/app/javascript/components/service/DialogGroups.jsx b/app/javascript/components/service/DialogGroups.jsx new file mode 100644 index 00000000000..b50cd77091b --- /dev/null +++ b/app/javascript/components/service/DialogGroups.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import DialogFields from './DialogFields'; + +/** Component to render the Groups in the Service/DialogTabs component */ +const DialogGroups = ({ dialogGroups }) => ( + <> + { + dialogGroups.map((item) => ( +
+
+ {item.label} +
+
+ +
+
+ )) + } + +); + +DialogGroups.propTypes = { + dialogGroups: PropTypes.arrayOf(PropTypes.any).isRequired, +}; + +export default DialogGroups; diff --git a/app/javascript/components/service/DialogTabs.jsx b/app/javascript/components/service/DialogTabs.jsx new file mode 100644 index 00000000000..b2c8123d7d7 --- /dev/null +++ b/app/javascript/components/service/DialogTabs.jsx @@ -0,0 +1,40 @@ +import React, { useContext } from 'react'; +import { Tabs, Tab, Loading } from 'carbon-components-react'; +import DialogGroups from './DialogGroups'; +import ServiceContext from './ServiceContext'; +import { extractDialogTabs } from './helper'; + +/** Component to render the Tabs in the Service component */ +const DialogTabs = () => { + const { data } = useContext(ServiceContext); + const dialogTabs = extractDialogTabs(data.apiResponse); + + const tabLabel = (label, tabIndex) => { + const { fieldsToRefresh, groupFieldsByTab } = data; + const refreshInProgress = fieldsToRefresh.some((field) => groupFieldsByTab[tabIndex].includes(field)); + return refreshInProgress + ? ( +
+ {label} + +
+ ) + : label; + }; + + return ( + + { + dialogTabs.map((tab, tabIndex) => ( + +
+ +
+
+ )) + } +
+ ); +}; + +export default DialogTabs; diff --git a/app/javascript/components/service/RefreshField.jsx b/app/javascript/components/service/RefreshField.jsx new file mode 100644 index 00000000000..523c4422c35 --- /dev/null +++ b/app/javascript/components/service/RefreshField.jsx @@ -0,0 +1,62 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import { Button, Loading } from 'carbon-components-react'; +import { Renew16 } from '@carbon/icons-react'; +import ServiceContext from './ServiceContext'; +import ServiceValidator from './ServiceValidator'; +import { defaultFieldValue } from './helper'; +import { fieldProperties } from './helper.field'; + +/** Function to reset the dialogField data when the field refresh button is clicked. */ +const resetDialogField = (dialogFields, field) => { + const { value, valid } = ServiceValidator.validateField({ field, value: defaultFieldValue(field) }); + dialogFields[field.name] = { value, valid }; + return { ...dialogFields }; +}; + +const RefreshField = ({ field }) => { + const { data, setData } = useContext(ServiceContext); + const { isDisabled } = fieldProperties(field, data); + + const { fieldsToRefresh } = data; + const inProgress = fieldsToRefresh.includes(field.name); + return ( +
+ { + !!(field.dynamic && field.show_refresh_button) && !inProgress && ( +
+ ); +}; + +RefreshField.propTypes = { + field: PropTypes.shape({ + label: PropTypes.string, + dynamic: PropTypes.bool, + show_refresh_button: PropTypes.bool, + dialog_field_responders: PropTypes.arrayOf(PropTypes.string), + name: PropTypes.string, + }).isRequired, +}; + +export default RefreshField; diff --git a/app/javascript/components/service/ServiceButtons.jsx b/app/javascript/components/service/ServiceButtons.jsx new file mode 100644 index 00000000000..a2a9ab6d28a --- /dev/null +++ b/app/javascript/components/service/ServiceButtons.jsx @@ -0,0 +1,84 @@ +import React, { useContext, useEffect } from 'react'; +import { Button } from 'carbon-components-react'; +import ServiceContext from './ServiceContext'; +import { omitValidation } from './helper'; +import miqRedirectBack from '../../helpers/miq-redirect-back'; + +const ServiceButtons = React.memo(() => { + const { data, setData } = useContext(ServiceContext); + const { + apiAction, apiSubmitEndpoint, openUrl, finishSubmitEndpoint, + } = data.urls; + + useEffect(() => { + if (data.locked) { + const handleSubmission = async() => { + const values = omitValidation(data.dialogFields); + let submitData = { action: 'order', ...values }; + + if (apiSubmitEndpoint.includes('/generic_objects/')) { + submitData = { action: apiAction, parameters: values }; + } else if (apiAction === 'reconfigure') { + submitData = { action: apiAction, resource: values }; + } + + try { + const response = await API.post(apiSubmitEndpoint, submitData, { skipErrors: [400] }); + if (openUrl === 'true') { + const taskResponse = await API.wait_for_task(response) + .then(() => + // eslint-disable-next-line no-undef + $http.post('open_url_after_dialog', { targetId, realTargetType })); + + 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(); + } + } else { + miqRedirectBack(__('Order Request was Submitted'), 'success', finishSubmitEndpoint); + } + } catch (_error) { + // Handle error if needed + } + }; + + handleSubmission(); + } + }, [data.locked]); + + const formValid = data.dialogFields ? Object.values(data.dialogFields).every((field) => field.valid) : false; + + const submitForm = () => { + miqSparkleOn(); + setData({ + ...data, + locked: true, + }); + }; + + return ( +
+ + + +
+ ); +}); + +export default ServiceButtons; diff --git a/app/javascript/components/service/ServiceContext.jsx b/app/javascript/components/service/ServiceContext.jsx new file mode 100644 index 00000000000..47b48de8721 --- /dev/null +++ b/app/javascript/components/service/ServiceContext.jsx @@ -0,0 +1,4 @@ +import { createContext } from 'react'; + +const ServiceContext = createContext(); +export default ServiceContext; diff --git a/app/javascript/components/service/ServiceValidator.js b/app/javascript/components/service/ServiceValidator.js new file mode 100644 index 00000000000..86f2d194616 --- /dev/null +++ b/app/javascript/components/service/ServiceValidator.js @@ -0,0 +1,117 @@ +import { DIALOG_FIELD_TYPES, ServiceType } from './constants'; + +class ServiceValidator { + constructor(serviceType) { + if (!ServiceValidator.instance) { + this.serviceType = serviceType; + ServiceValidator.instance = this; + } + return ServiceValidator.instance; + } + + static validateField(data) { + switch (ServiceValidator.instance.serviceType) { + case ServiceType.order: + return this.orderServiceValidation(data); + case ServiceType.dialog: + case ServiceType.request: + default: + return ({ valid: true, value: data.value }); + } + } + + static orderServiceValidation(data) { + const validationMap = { + [DIALOG_FIELD_TYPES.checkBox]: this.checkbox, + [DIALOG_FIELD_TYPES.date]: this.date, + [DIALOG_FIELD_TYPES.dateTime]: this.dateTime, + [DIALOG_FIELD_TYPES.dropDown]: this.dropDown, + [DIALOG_FIELD_TYPES.radio]: this.radio, + [DIALOG_FIELD_TYPES.tag]: this.tag, + [DIALOG_FIELD_TYPES.textBox]: this.textBox, + [DIALOG_FIELD_TYPES.textArea]: this.textArea, + }; + const validateFn = validationMap[data.field.type] || this.default; + return validateFn.call(this, data); + } + + static checkbox(data) { + return { valid: (data.field.required ? !!data.value : true), value: data.value !== '' }; + } + + static date({ field, value }) { + const { day, month, year } = value; + const hasDate = !!((day && month && year)); + return { valid: field.required ? hasDate : true, value: { day, month, year } }; + } + + static time({ field, value }) { + const { hour, minute, meridiem } = value; + const hasMinute = !!((hour && { hour, minute, meridiem })); + return { valid: field.required ? hasMinute : true, value: { hour, minute, meridiem } }; + } + + static dateTime(data) { + const date = this.date(data); + const time = this.time(data); + return { valid: data.field.required ? !!(date.valid && time.valid) : true, value: { ...date.value, ...time.value } }; + } + + static dropDown(data) { + const isMulti = !!(data.field.options && data.field.options.force_multi_value); + const valid = isMulti ? !!(data.value.length > 0) : !!data.value.id; + return { valid: data.field.required ? valid : true, value: data.value }; + } + + static radio(data) { + return { valid: data.field.required ? !!data.value : true, value: data.value }; + } + + static tag(data) { + const isMulti = !!(data.field.options && !data.field.options.force_single_value); + const valid = isMulti ? !!(data.value.length > 0) : !!data.value.id; + return { valid: data.field.required ? valid : true, value: data.value }; + } + + static textBox({ field, value }) { + return this.commonFieldValidation(field, value); + } + + static textArea({ field, value }) { + return this.commonFieldValidation(field, value); + } + + /** Regular expression is handled only for TextInput and TextArea */ + static regularExpressionValidation(field, value) { + if (field.validator_type === 'regex' && field.validator_rule) { + try { + if (typeof field.validator_rule === 'string') { + field.validator_rule = new RegExp(field.validator_rule); + } + const aaa = field.validator_rule.test(value); + return aaa ? { valid: true, value, message: undefined } + : { valid: false, message: field.validator_message || __('Custom Validation failed'), value }; + } catch (error) { + console.error('Unexpected error occurred when the field was validated using regular expression.', error); + throw error; + } + } + return undefined; + } + + /** Validation for TextInput, TextArea. */ + static commonFieldValidation(field, value) { + const custom = this.regularExpressionValidation(field, value); + if (field.required) { + if (custom) { + return custom.valid + ? { valid: true, value } + : { valid: false, value, message: custom.message }; + } + return { valid: !!value, value }; + } + return { valid: true, value }; + } +} + +export default ServiceValidator; diff --git a/app/javascript/components/service/constants.js b/app/javascript/components/service/constants.js new file mode 100644 index 00000000000..d5c2178db29 --- /dev/null +++ b/app/javascript/components/service/constants.js @@ -0,0 +1,21 @@ +export const DIALOG_FIELD_TYPES = { + checkBox: 'DialogFieldCheckBox', + date: 'DialogFieldDateControl', + dateTime: 'DialogFieldDateTimeControl', + dropDown: 'DialogFieldDropDownList', + radio: 'DialogFieldRadioButton', + tag: 'DialogFieldTagControl', + textBox: 'DialogFieldTextBox', + textArea: 'DialogFieldTextAreaBox', +}; + +export const ServiceType = { + order: 'orderServiceForm', + dialog: 'serviceDialog', + request: 'serviceRequest', +}; + +export const RefreshStatus = { + notStarted: 'notStarted', + completed: 'completed', +}; diff --git a/app/javascript/components/service/dialogFieldItems/CheckboxField.jsx b/app/javascript/components/service/dialogFieldItems/CheckboxField.jsx new file mode 100644 index 00000000000..8ada161e85f --- /dev/null +++ b/app/javascript/components/service/dialogFieldItems/CheckboxField.jsx @@ -0,0 +1,58 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import { Checkbox } from 'carbon-components-react'; +import { fieldProperties } from '../helper.field'; +import FieldLabel from './FieldLabel'; +import ServiceContext from '../ServiceContext'; +import ServiceValidator from '../ServiceValidator'; + +/** Component to render the Checkbox in the Service/DialogTabs/DialogGroups/DialogFields component */ +const CheckboxField = ({ field }) => { + const { data, setData } = useContext(ServiceContext); + const { + fieldData, isDisabled, fieldId, requiredLabel, + } = fieldProperties(field, data); + + /** Checkbox onChange event handler */ + const onChange = (checked) => { + if (data.isOrderServiceForm) { + const { valid, value } = ServiceValidator.validateField({ value: checked, field }); + data.dialogFields[field.name] = { ...data.dialogFields[field.name], value, valid }; + setData({ + ...data, + dialogFields: { ...data.dialogFields }, + fieldsToRefresh: field.dialog_field_responders, + }); + } + }; + + return ( +
+ } + onChange={(checked) => onChange(checked)} + readOnly={field.read_only} + /> + { + !fieldData.valid &&
{requiredLabel}
+ } +
+ ); +}; + +CheckboxField.propTypes = { + field: PropTypes.shape({ + id: PropTypes.string, + type: PropTypes.string, + default_value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + dialog_field_responders: PropTypes.arrayOf(PropTypes.string), + label: PropTypes.string, + name: PropTypes.string, + required: PropTypes.bool, + read_only: PropTypes.bool, + }).isRequired, +}; + +export default CheckboxField; diff --git a/app/javascript/components/service/dialogFieldItems/DateField.jsx b/app/javascript/components/service/dialogFieldItems/DateField.jsx new file mode 100644 index 00000000000..b9bcd276561 --- /dev/null +++ b/app/javascript/components/service/dialogFieldItems/DateField.jsx @@ -0,0 +1,68 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import { DatePicker, DatePickerInput } from 'carbon-components-react'; +import { fieldProperties } from '../helper.field'; +import { extractDate, dateString } from '../helper.dateTime'; +import ServiceContext from '../ServiceContext'; +import ServiceValidator from '../ServiceValidator'; + +/** Component to render the Radio buttons in the Service/DialogTabs/DialogGroups/DialogFields component */ +const DateField = ({ field }) => { + const { data, setData } = useContext(ServiceContext); + const { + fieldData, isDisabled, fieldId, requiredLabel, + } = fieldProperties(field, data); + const selectedDate = dateString(fieldData.value); + + /** DatePicker's onChange event handler */ + const onChange = (selectedItem) => { + if (data.isOrderServiceForm) { + const extractedDate = extractDate(selectedItem[0]); + const { valid, value } = ServiceValidator.validateField({ field, value: extractedDate }); + data.dialogFields[field.name] = { ...data.dialogFields[field.name], value, valid }; + setData({ + ...data, + dialogFields: { ...data.dialogFields }, + fieldsToRefresh: field.dialog_field_responders, + }); + } + }; + + return ( +
+ + + +
+ ); +}; + +DateField.propTypes = { + field: PropTypes.shape({ + id: PropTypes.string, + type: PropTypes.string, + default_value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + dialog_field_responders: PropTypes.arrayOf(PropTypes.string), + values: PropTypes.arrayOf(PropTypes.any), + label: PropTypes.string, + name: PropTypes.string, + required: PropTypes.bool, + read_only: PropTypes.bool, + }).isRequired, +}; + +export default DateField; diff --git a/app/javascript/components/service/dialogFieldItems/DateTimeField.jsx b/app/javascript/components/service/dialogFieldItems/DateTimeField.jsx new file mode 100644 index 00000000000..50b5c971ede --- /dev/null +++ b/app/javascript/components/service/dialogFieldItems/DateTimeField.jsx @@ -0,0 +1,133 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import { DatePicker, Dropdown, DatePickerInput } from 'carbon-components-react'; +import { fieldProperties } from '../helper.field'; +import { currentDateTime, dateTimeString, extractDateTime } from '../helper.dateTime'; +import ServiceContext from '../ServiceContext'; +import ServiceValidator from '../ServiceValidator'; + +/** Component to render the Radio buttons in the Service/DialogTabs/DialogGroups/DialogFields component */ +const DateTimeField = ({ field }) => { + const { data, setData } = useContext(ServiceContext); + const { + fieldData, isDisabled, fieldId, requiredLabel, + } = fieldProperties(field, data); + const selectedDateTime = dateTimeString(fieldData.value); + const { hours, minutes } = currentDateTime(); + + const updateDateTime = (fieldData) => { + data.dialogFields[field.name] = { ...data.dialogFields[field.name], ...fieldData }; + setData({ + ...data, + dialogFields: { ...data.dialogFields }, + fieldsToRefresh: field.dialog_field_responders, + }); + }; + + /** Function to handle the hour's onChange event. */ + const onHourChange = ({ selectedItem }) => { + if (data.isOrderServiceForm) { + fieldData.value.hour = selectedItem.id; + updateDateTime({ ...fieldData }); + } + }; + + /** Function to handle the minute's onChange event. */ + const onMinuteChange = ({ selectedItem }) => { + if (data.isOrderServiceForm) { + fieldData.value.minute = selectedItem.id; + updateDateTime({ ...fieldData }); + } + }; + + /** Function to handle the AM/PM onChange event. */ + const onMeridiemChange = ({ selectedItem }) => { + if (data.isOrderServiceForm) { + fieldData.value.meridiem = selectedItem.id; + updateDateTime({ ...fieldData }); + } + }; + + /** Function to handle the date's onChange event. */ + const onDateChange = (selectedItem) => { + if (data.isOrderServiceForm) { + const extractedDateTime = extractDateTime(selectedItem[0], selectedDateTime); + const { valid, value } = ServiceValidator.validateField({ field, value: extractedDateTime }); + updateDateTime({ value, valid, type: field.type }); + } + }; + + return ( +
+ + + + (item ? item.text : '')} + onChange={onHourChange} + readOnly={field.read_only} + /> + (item ? item.text : '')} + onChange={onMinuteChange} + readOnly={field.read_only} + /> + ({ id: item, text: item }))} + itemToString={(item) => (item ? item.text : '')} + onChange={onMeridiemChange} + readOnly={field.read_only} + /> +
+ ); +}; + +DateTimeField.propTypes = { + field: PropTypes.shape({ + id: PropTypes.string, + type: PropTypes.string, + default_value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + dialog_field_responders: PropTypes.arrayOf(PropTypes.string), + values: PropTypes.arrayOf(PropTypes.any), + label: PropTypes.string, + name: PropTypes.string, + required: PropTypes.bool, + read_only: PropTypes.bool, + }).isRequired, +}; + +export default DateTimeField; diff --git a/app/javascript/components/service/dialogFieldItems/DropDownField.jsx b/app/javascript/components/service/dialogFieldItems/DropDownField.jsx new file mode 100644 index 00000000000..7220ebf9555 --- /dev/null +++ b/app/javascript/components/service/dialogFieldItems/DropDownField.jsx @@ -0,0 +1,34 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import MultiDropDownField from './DropDowns/MultiDropDownField'; +import SimpleDropDownField from './DropDowns/SimpleDropDownField'; +import ServiceContext from '../ServiceContext'; +import { defaultFieldOptions } from '../helper'; + +/** Component to render the DropDownField in the Service/DialogTabs/DialogGroups/DialogFields component */ +const DropDownField = ({ field }) => { + const { data } = useContext(ServiceContext); + const isMulti = !!(field.options && field.options.force_multi_value); + const options = defaultFieldOptions(field, data); + + return isMulti + ? + : ; +}; + +DropDownField.propTypes = { + field: PropTypes.shape({ + id: PropTypes.string, + options: PropTypes.shape({ + force_multi_value: PropTypes.bool, + }), + default_value: PropTypes.string, + label: PropTypes.string.isRequired, + name: PropTypes.string, + type: PropTypes.string, + values: PropTypes.arrayOf(PropTypes.any), + required: PropTypes.bool, + }).isRequired, +}; + +export default DropDownField; diff --git a/app/javascript/components/service/dialogFieldItems/DropDowns/MultiDropDownField.jsx b/app/javascript/components/service/dialogFieldItems/DropDowns/MultiDropDownField.jsx new file mode 100644 index 00000000000..b2f6e8f0412 --- /dev/null +++ b/app/javascript/components/service/dialogFieldItems/DropDowns/MultiDropDownField.jsx @@ -0,0 +1,70 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import { FilterableMultiSelect } from 'carbon-components-react'; +import { fieldProperties } from '../../helper.field'; +import FieldLabel from '../FieldLabel'; +import ServiceValidator from '../../ServiceValidator'; +import ServiceContext from '../../ServiceContext'; + +/** Component to render the MultiDropDownField in the Service/DialogTabs/DialogGroups/DialogFields component */ +const MultiDropDownField = ({ field, options }) => { + const { data, setData } = useContext(ServiceContext); + const { + fieldData, isDisabled, fieldId, requiredLabel, + } = fieldProperties(field, data); + + /** FilterableMultiSelect onChange event handler */ + const onChange = ({ selectedItems }) => { + if (data.isOrderServiceForm) { + const { valid, value } = ServiceValidator.validateField({ value: selectedItems, field }); + data.dialogFields[field.name] = { ...data.dialogFields[field.name], value, valid }; + setData({ + ...data, + dialogFields: { ...data.dialogFields }, + fieldsToRefresh: field.dialog_field_responders, + }); + } + }; + + /** Custom sort function that does nothing to maintain the order of items. sorting is already handled in helper functions. + * Or we han avoid the helper functions mad implement a logic here using the field.options.sort_order property. (easy way). + */ + const noSort = (items) => items; + + return ( + } + initialSelectedItems={fieldData.value} + placeholder={fieldData.value.length <= 0 ? __('Nothing selected') : ''} + invalidText={requiredLabel} + items={options.map((item) => ({ ...item, label: item.text }))} + itemToString={(item) => (item ? item.text : '')} + onChange={onChange} + selectionFeedback="top-after-reopen" + readOnly={field.read_only} + sortItems={noSort} + /> + ); +}; + +MultiDropDownField.propTypes = { + field: PropTypes.shape({ + id: PropTypes.string, + type: PropTypes.string, + name: PropTypes.string, + dialog_field_responders: PropTypes.arrayOf(PropTypes.string), + label: PropTypes.string.isRequired, + required: PropTypes.bool, + read_only: PropTypes.bool, + }).isRequired, + options: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + text: PropTypes.string.isRequired, + label: PropTypes.string, + })).isRequired, +}; + +export default MultiDropDownField; diff --git a/app/javascript/components/service/dialogFieldItems/DropDowns/SimpleDropDownField.jsx b/app/javascript/components/service/dialogFieldItems/DropDowns/SimpleDropDownField.jsx new file mode 100644 index 00000000000..ebb8a057df0 --- /dev/null +++ b/app/javascript/components/service/dialogFieldItems/DropDowns/SimpleDropDownField.jsx @@ -0,0 +1,68 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import { Dropdown } from 'carbon-components-react'; +import { fieldProperties } from '../../helper.field'; +import FieldLabel from '../FieldLabel'; +import ServiceContext from '../../ServiceContext'; +import ServiceValidator from '../../ServiceValidator'; + +/** Component to render the SimpleDropDownField in the Service/DialogTabs/DialogGroups/DialogFields component */ +const SimpleDropDownField = ({ field, options }) => { + const { data, setData } = useContext(ServiceContext); + const { + fieldData, isDisabled, fieldId, requiredLabel, + } = fieldProperties(field, data); + + /** Dropdown onChange event handler */ + const onChange = ({ selectedItem }) => { + const { valid, value } = ServiceValidator.validateField({ value: selectedItem, field }); + data.dialogFields[field.name] = { ...data.dialogFields[field.name], value, valid }; + if (data.isOrderServiceForm) { + setData({ + ...data, + dialogFields: { ...data.dialogFields }, + fieldsToRefresh: field.dialog_field_responders, + }); + } else { + setData({ + ...data, + dialogFields: { ...data.dialogFields }, + }); + } + }; + + return ( + } + initialSelectedItem={options[0]} + selectedItem={fieldData.value} + invalidText={requiredLabel} + label={__('Nothing selected')} + items={options} + itemToString={(item) => (item ? item.text : '')} + onChange={onChange} + readOnly={field.read_only} + /> + ); +}; + +SimpleDropDownField.propTypes = { + field: PropTypes.shape({ + id: PropTypes.string, + type: PropTypes.string, + name: PropTypes.string, + dialog_field_responders: PropTypes.arrayOf(PropTypes.string), + label: PropTypes.string.isRequired, + required: PropTypes.bool, + read_only: PropTypes.bool, + }).isRequired, + options: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + text: PropTypes.string.isRequired, + })).isRequired, +}; + +export default SimpleDropDownField; diff --git a/app/javascript/components/service/dialogFieldItems/FieldLabel.jsx b/app/javascript/components/service/dialogFieldItems/FieldLabel.jsx new file mode 100644 index 00000000000..51101e422d8 --- /dev/null +++ b/app/javascript/components/service/dialogFieldItems/FieldLabel.jsx @@ -0,0 +1,55 @@ +import React, { useMemo, useContext } from 'react'; +import PropTypes from 'prop-types'; +import { InformationFilled16 } from '@carbon/icons-react'; +import { TooltipIcon, Tag } from 'carbon-components-react'; +import ServiceContext from '../ServiceContext'; + +/** Function to render a Tag when the field refresh is in progress. */ +const refreshingLabel = (label) => {__(`Refreshing ${label}...`)}; + +/** Function to render a ToolTip when a field description is available. */ +const getTooltip = (description) => ( + description ? ( +
+ + + +
+ ) : null +); + +/** Function to render refreshing label for a field. */ +const getFieldLabelContent = (field, data) => { + if (data.fieldsToRefresh.includes(field.name)) { + return refreshingLabel(field.label); + } + return ( + <> + {data.isOrderServiceForm && field.required && *} + {field.label} + {getTooltip(field.description)} + + ); +}; + +/** Component to render the fields label */ +const FieldLabel = React.memo(({ field }) => { + const { data } = useContext(ServiceContext); + const labelContent = useMemo(() => getFieldLabelContent(field, data), [field, data]); + return ( +
+ {labelContent} +
+ ); +}); + +FieldLabel.propTypes = { + field: PropTypes.shape({ + label: PropTypes.string.isRequired, + required: PropTypes.bool, + name: PropTypes.string.isRequired, + description: PropTypes.string, + }).isRequired, +}; + +export default FieldLabel; diff --git a/app/javascript/components/service/dialogFieldItems/RadioField.jsx b/app/javascript/components/service/dialogFieldItems/RadioField.jsx new file mode 100644 index 00000000000..a962a3704e3 --- /dev/null +++ b/app/javascript/components/service/dialogFieldItems/RadioField.jsx @@ -0,0 +1,72 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import { RadioButtonGroup, RadioButton } from 'carbon-components-react'; +import { fieldProperties } from '../helper.field'; +import FieldLabel from './FieldLabel'; +import ServiceContext from '../ServiceContext'; +import ServiceValidator from '../ServiceValidator'; + +/** Component to render the Radio buttons in the Service/DialogTabs/DialogGroups/DialogFields component */ +const RadioField = ({ field }) => { + const { data, setData } = useContext(ServiceContext); + const { + fieldData, isDisabled, fieldId, requiredLabel, + } = fieldProperties(field, data); + + /** Function to handle the RadioButton's onChange event. */ + const onChange = (selectedValue) => { + if (data.isOrderServiceForm) { + const { valid, value } = ServiceValidator.validateField({ value: selectedValue, field }); + data.dialogFields[field.name] = { ...data.dialogFields[field.name], value, valid }; + setData({ + ...data, + dialogFields: { ...data.dialogFields }, + fieldsToRefresh: field.dialog_field_responders, + }); + } + }; + + return ( +
+ } + name={field.name} + id={fieldId} + readOnly={field.read_only} + invalid={!fieldData.valid} + invalidText={requiredLabel} + onChange={onChange} + valueSelected={fieldData.value} // Ensure the selected value is correctly handled + > + { + field.values.map((radio) => ( + + )) + } + + {field.required && !fieldData.valid &&
{requiredLabel}
} +
+ ); +}; + +RadioField.propTypes = { + field: PropTypes.shape({ + id: PropTypes.string, + type: PropTypes.string, + default_value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + dialog_field_responders: PropTypes.arrayOf(PropTypes.string), + values: PropTypes.arrayOf(PropTypes.any), + label: PropTypes.string, + name: PropTypes.string, + required: PropTypes.bool, + read_only: PropTypes.bool, + }).isRequired, +}; + +export default RadioField; diff --git a/app/javascript/components/service/dialogFieldItems/TagField.jsx b/app/javascript/components/service/dialogFieldItems/TagField.jsx new file mode 100644 index 00000000000..d744073f897 --- /dev/null +++ b/app/javascript/components/service/dialogFieldItems/TagField.jsx @@ -0,0 +1,34 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import MultiDropDownField from './DropDowns/MultiDropDownField'; +import SimpleDropDownField from './DropDowns/SimpleDropDownField'; +import ServiceContext from '../ServiceContext'; +import { defaultFieldOptions } from '../helper'; + +/** Component to render the TagField in the Service/DialogTabs/DialogGroups/DialogFields component */ +const TagField = ({ field }) => { + const { data } = useContext(ServiceContext); + const isMulti = !!(field.options && !field.options.force_single_value); + const options = defaultFieldOptions(field, data); + return isMulti + ? + : ; +}; + +TagField.propTypes = { + field: PropTypes.shape({ + id: PropTypes.string, + options: PropTypes.shape({ + force_multi_value: PropTypes.bool, + force_single_value: PropTypes.bool, + }), + default_value: PropTypes.string, + label: PropTypes.string.isRequired, + name: PropTypes.string, + type: PropTypes.string, + values: PropTypes.arrayOf(PropTypes.any), + required: PropTypes.bool, + }).isRequired, +}; + +export default TagField; diff --git a/app/javascript/components/service/dialogFieldItems/TextAreaField.jsx b/app/javascript/components/service/dialogFieldItems/TextAreaField.jsx new file mode 100644 index 00000000000..a3aea572b19 --- /dev/null +++ b/app/javascript/components/service/dialogFieldItems/TextAreaField.jsx @@ -0,0 +1,57 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import { TextArea } from 'carbon-components-react'; +import { fieldProperties } from '../helper.field'; +import FieldLabel from './FieldLabel'; +import ServiceContext from '../ServiceContext'; +import ServiceValidator from '../ServiceValidator'; + +/** Component to render the TextAreaField in the Service/DialogTabs/DialogGroups/DialogFields component */ +const TextAreaField = ({ field }) => { + const { data, setData } = useContext(ServiceContext); + const { + fieldData, isDisabled, fieldId, requiredLabel, + } = fieldProperties(field, data); + + /** Function to handle the TextArea's onChange event. */ + const onChange = (event) => { + if (data.isOrderServiceForm) { + const { valid, value, message } = ServiceValidator.validateField({ value: event.target.value, field }); + data.dialogFields[field.name] = { ...data.dialogFields[field.name], value, valid, message }; + setData({ + ...data, + dialogFields: { ...data.dialogFields }, + fieldsToRefresh: field.dialog_field_responders, + }); + } + }; + + return ( + +
+ + +
+ +
+
+
+ +
+ + } + onChange={[Function]} + readOnly={false} + > +
+ + +
+
+
+
+
+ +
+ +
+
+
+ + + + } + type="default" + > +
+ + + + + + +
+ +
+ + +
+
+
+
+
+ + + + } + > + + +
+ + +
+
+ + + + +
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+ +
+ + } + name="radio_button_1" + onChange={[Function]} + orientation="horizontal" + readOnly={false} + valueSelected="" + > +
+
+ + + + +
+ Radio Button +
+
+
+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+ + +
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+ + + + } + > + + +
+ + +
+
+ + + + +
+
+
+
+
+
+
+
+
+
+ +
+ +
+ +
+
+ +
+
+ + + +
+ + +`; diff --git a/app/javascript/spec/service/__snapshots__/order-service-form.spec.js.snap b/app/javascript/spec/service/__snapshots__/order-service-form.spec.js.snap new file mode 100644 index 00000000000..c53c691ae9f --- /dev/null +++ b/app/javascript/spec/service/__snapshots__/order-service-form.spec.js.snap @@ -0,0 +1,5294 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Service component - Order Service should render the Service component for orderServiceForm that renders all fields 1`] = ` + + +
+ + +
+ +
    + +
  • + +
  • +
    +
+ +
+