Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/javascript/components/automate-entry-points/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@
margin-bottom: 1rem;
}

.bx--modal-container {
overflow-x: scroll;
}

.bx--btn--primary {
margin-right: 10px;
}
Expand Down
44 changes: 44 additions & 0 deletions app/javascript/components/common/inline-flash-message/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from 'react';
Copy link
Contributor

Choose a reason for hiding this comment

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

I think in React-17+, JSX works without importing React, so we should be able to drop it post our upgrade 👍

import PropTypes from 'prop-types';
import { InlineNotification } from 'carbon-components-react';

/**
* Inline flash message for showing notifications.
*
* @param {Object} message - The notification details to display (kind, title, subtitle).
* If `null` or `undefined`, no notification is shown.
* @param {Function} setMessage - Callback for handling close button clicks.
* @param {boolean} showCloseButton - Whether to display the close button.
*/
const InlineFlashMessage = ({ message, setMessage, showCloseButton }) => {
if (!message) return null;

return (
<InlineNotification
kind={message.kind || 'info'} // "success" | "error" | "info" | "warning"
title={message.title || ''}
subtitle={message.subtitle || ''}
lowContrast
hideCloseButton={!showCloseButton}
onCloseButtonClick={setMessage}
/>
);
};

InlineFlashMessage.propTypes = {
message: PropTypes.shape({
kind: PropTypes.oneOf(['success', 'error', 'info', 'warning']),
title: PropTypes.string,
subtitle: PropTypes.string,
}),
setMessage: PropTypes.func,
showCloseButton: PropTypes.bool,
};

InlineFlashMessage.defaultProps = {
message: null,
Copy link
Contributor

@asirvadAbrahamVarghese asirvadAbrahamVarghese Aug 27, 2025

Choose a reason for hiding this comment

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

since we are doing if (!message) return null; at the top of the component, we can safely skip setting message as null from default props unless its marked required by PropTypes.shape({}).isRequired - the value received will be undefined and would still match our if(!message) condition

setMessage: () => {},
showCloseButton: true,
};

export default InlineFlashMessage;
95 changes: 95 additions & 0 deletions app/javascript/components/date-time-picker/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React, { useState } from 'react';
import {
DatePicker,
DatePickerInput,
TimePicker,
TimePickerSelect,
SelectItem,
FormLabel,
} from 'carbon-components-react';
import { getCurrentDate, getCurrentTimeAndPeriod } from '../service-dialog-form/helper';
Copy link
Contributor

Choose a reason for hiding this comment

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

Dealing with dates can be bug-prone. we should be fine for now with simple date logics but as logic grows, pulling in a utility like date-fns would help.


const CustomDateTimePicker = (field) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Correct me here if I am wrong
We will be using the component like:

        <CustomDateTimePicker
          label="Custom Date Time Picker"
          initialData={{ date: '11/06/2025', time: '23:11', period: 'PM' }}
          onChange={(data) => {
            // any actions with on change data
          }}
        />

So I was thinking - just like we did with InlineFlashMessage, inline destructuring of component props is generally cleaner instead of accessing them via field object:
const CustomDateTimePicker = ({ label, onChange, initialData }) => {...

and then add props validations and default props for them:

CustomDateTimePicker.propTypes = {
  initialData: PropTypes.shape({
    date: PropTypes.string,
    time: PropTypes.string,
    period: PropTypes.string,
  }),
  onChange: PropTypes.func,
  label: PropTypes.string,
};

CustomDateTimePicker.defaultProps = {
  initialData: { date: '', time: '', period: '' },
  onChange: () => {},
  label: '',
};

Note: date, time and period having an empty string as default value ({ date: '', time: '', period: '' }) shouldn't be a problem as they are falsy in JS, fallback methods like getCurrentDate and getCurrentTimeAndPeriod will still be triggered to set states initial values

const { initialData, onChange } = field;

const [date, setDate] = useState(initialData.date || getCurrentDate);
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess it would be better to call the function immediately (getCurrentDate) instead of passing the reference for React to call it automatically to get the initial state value when initialData.date is not available:
const [date, setDate] = useState(() => initialData.date || getCurrentDate());

also include the lazy initialization () =>🔝, like we did with time and period states initializations, so that the arrow function is only executed during the initial render, on subsequent re-renders, react knows it already has a state value and doesn't need to call the function again. The performance difference would be negligible here I believe, but it's better to use the arrow function for any initialization that creates objects, makes calculations or anything that could potentially be expensive.

const [time, setTime] = useState(() => initialData.time || getCurrentTimeAndPeriod().time);
Copy link
Contributor

Choose a reason for hiding this comment

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

On initial render, time would look like this which is not in our expected "hh:mm" format, it should have been "09:54", But the invalid time error: "Enter a valid 12-hour time" is not displayed, probably thats because isValid state is initially true
image
Right after time field is edited the error is displayed since our state isValid state is now updated:
image

No issues with this implementation, this should work well once we apply zero-padding to the hour value in getCurrentTimeAndPeriod(similar to what we did with minutes).
It should be padded after converting to 12-hour format, otherwise, the modulo will reduce it to a single digit again.
hours = ${hours % 12 || 12}.padStart(2, '0');
Which should work well:
image

const [isValid, setIsValid] = useState(true);
const [period, setPeriod] = useState(() => initialData.period || getCurrentTimeAndPeriod().period);

const combinedDateTime = () => {
const dateTime = `${date} ${time} ${period}`;
return dateTime;
};

const handleDateChange = (newDate) => {
if (newDate.length > 0) {
Copy link
Contributor

Choose a reason for hiding this comment

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

if (newDate.length) should be fine here since 0 is also falsy in JS and any number greater than 0 will be truthy

const formattedDate = new Intl.DateTimeFormat('en-US', {
month: '2-digit',
day: '2-digit',
year: 'numeric',
}).format(newDate[0]);
setDate(formattedDate);
onChange({ value: combinedDateTime(), initialData });
Copy link
Contributor

Choose a reason for hiding this comment

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

Like discussed below, due to batched state updates we might want to update combinedDateTime to receive the updated date string:

  const combinedDateTime = ({ dateValue, timeValue, periodValue } = {}) =>
    `${dateValue || date} ${timeValue || time} ${periodValue || period}`;

and then from handleDateChange pass the date string value:

  const handleDateChange = (newDate) => {
    if (newDate.length) {
      const formattedDate = new Intl.DateTimeFormat('en-US', {
      .....
      onChange({
        value: combinedDateTime({ dateValue: formattedDate }),
        initialData,
      });
    }
  };

}
};

// Function to validate the time input
const validateTime = (value) => {
const timeRegex = /^(0[1-9]|1[0-2]):[0-5][0-9]$/; // Matches 12-hour format hh:mm
setIsValid(timeRegex.test(value));
};

const handleTimeChange = (event) => {
const newTime = event.target.value;
setTime(newTime);
validateTime(newTime);
if (isValid) onChange({ value: combinedDateTime(), initialData });
Copy link
Contributor

Choose a reason for hiding this comment

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

Tried "11:5" in the time field, which doesn't match our pattern, so it's treated as an invalid time, the regex correctly returns false, which we also assign to the isValid state:
image
Given the condition(if (isValid) onChange({ ... });), we don't expect the onChange prop to be triggered in this case but it still gets called since isValid is still true(initial state value is true):
image
This is because state updates are asynchronous & batched and won't be reflecting immediately, when setIsValid is called react schedules the state update and updated value will be available only in the next render(only after the current event scope) not immediately after the call. So within the same event, we will still be accessing the previous state(which is true).

Returning the regex result from validateTime and using it for both setting state and controlling onChange should work fine:

  const validateTime = (value) => {
    const timeRegex = /^(0[1-9]|1[0-2]):[0-5][0-9]$/; // Matches 12-hour format hh:mm
    return timeRegex.test(value);
  };
  const handleTimeChange = (event) => {
    const newTime = event.target.value;
    setTime(newTime);
    const isValidTime = validateTime(newTime);
    setIsValid(isValidTime);
    if (isValidTime) onChange({ value: combinedDateTime(), initialData });
  };

Copy link
Contributor

Choose a reason for hiding this comment

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

The combinedDateTime function is also impacted the same way, we are updating the time state from here & then trying to access the state value from the same event scope, we will be having the previous time value due to react's batched updates.
Time field value is updated to "12:53" but time state will still provide its previous value "12:5" inside the same method scope:
image

Instead of relying on stale state we could update combinedDateTime to use the same input we're using for the state (in a way that it uses parameter value if available or the state value, thus other state values(date & period) are set):

  const combinedDateTime = ({ dateValue, timeValue, periodValue } = {}) =>
    `${dateValue || date} ${timeValue || time} ${periodValue || period}`;

🔝 we can also avoid the intermediate variable(const dateTime) by returning the string immediately

Now from handleTimeChange we can pass the value for time like:

    if (isValidTime) {
      onChange({
        value: combinedDateTime({ timeValue: newTime }),
        initialData,
      });
    }

So that correct updated time is returned from combinedDateTime
image

};

const handlePeriodChange = (event) => {
setPeriod(event.target.value);
onChange({ value: combinedDateTime(), initialData });
Copy link
Contributor

Choose a reason for hiding this comment

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

Same as above, due to batched state updates we might want to update combinedDateTime to receive the updated period string:

  const combinedDateTime = ({ dateValue, timeValue, periodValue } = {}) =>
    `${dateValue || date} ${timeValue || time} ${periodValue || period}`;

and then from handlePeriodChange pass the updated period value:

  const handlePeriodChange = (event) => {
    const newPeriod = event.target.value;
    setPeriod(newPeriod);
    onChange({
      value: combinedDateTime({ periodValue: newPeriod }),
      initialData,
    });
  };

};

return (
<div>
<FormLabel>{field.label}</FormLabel>
Copy link
Contributor

Choose a reason for hiding this comment

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

Is the label prop expected to always be provided? The label element still gets rendered in the DOM even when no label is provided:
image
conditionally rendering it can help prevent unused elements in the DOM:
{label && <FormLabel>{label}</FormLabel>}
So that Label won't be rendered:
image

<DatePicker
datePickerType="single"
onChange={handleDateChange}
>
<DatePickerInput
id="date-picker-single"
placeholder="mm/dd/yyyy"
labelText={__('Select Date')}
value={date}
hideLabel
onChange={handleDateChange}
/>
<TimePicker
id="time-picker"
placeholder="hh:mm"
labelText={__('Select Time')}
invalid={!isValid}
invalidText="Enter a valid 12-hour time (e.g., 01:30)"
hideLabel
value={time}
onChange={handleTimeChange}
>
<TimePickerSelect
id="time-picker-select-1"
labelText={__('Select Period')}
defaultValue={period}
onChange={handlePeriodChange}
>
<SelectItem value="AM" text="AM" />
<SelectItem value="PM" text="PM" />
</TimePickerSelect>
</TimePicker>
</DatePicker>
</div>
);
};

export default CustomDateTimePicker;
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,14 @@ const EmbeddedAutomateEntryPoint = (props) => {
useEffect(() => {
if (selectedValue && selectedValue.name && selectedValue.name.text) {
selectedValue.name.text = textValue;
input.onChange(selectedValue);
} else if (!selectedValue || Object.keys(selectedValue).length === 0) {
// When selectedValue is empty or undefined, pass null to trigger validation
input.onChange(null);
} else {
input.onChange(selectedValue);
}
input.onChange(selectedValue);
}, [textValue]);
}, [textValue, selectedValue]);

return (
<div>
Expand All @@ -56,7 +61,7 @@ const EmbeddedAutomateEntryPoint = (props) => {
/>
<div className="entry-point-wrapper">
<div className="entry-point-text-input">
<TextInput id={id} type="text" labelText={__(label)} onChange={(value) => setTextValue(value.target.value)} value={textValue} />
<TextInput id={id} type="text" labelText={__(label)} onChange={(value) => setTextValue(value.target.value)} value={textValue} readOnly />
</div>
<div className="entry-point-buttons">
<div className="entry-point-open">
Expand All @@ -75,6 +80,8 @@ const EmbeddedAutomateEntryPoint = (props) => {
onClick={() => {
setSelectedValue({});
setTextValue('');
// Ensure the input change is triggered to update form state
input.onChange(null);
}}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,14 @@ const EmbeddedWorkflowEntryPoint = (props) => {
useEffect(() => {
if (selectedValue && selectedValue.name && selectedValue.name.text) {
selectedValue.name.text = textValue;
input.onChange(selectedValue);
} else if (!selectedValue || Object.keys(selectedValue).length === 0) {
// When selectedValue is empty or undefined, pass null to trigger validation
input.onChange(null);
} else {
input.onChange(selectedValue);
}
input.onChange(selectedValue);
}, [textValue]);
}, [textValue, selectedValue]);

return (
<div>
Expand All @@ -43,7 +48,7 @@ const EmbeddedWorkflowEntryPoint = (props) => {
) : undefined}
<div className="entry-point-wrapper">
<div className="entry-point-text-input">
<TextInput id={id} type="text" labelText={__(label)} onChange={(value) => setTextValue(value.target.value)} value={textValue} />
<TextInput id={id} type="text" labelText={__(label)} onChange={(value) => setTextValue(value.target.value)} value={textValue} readOnly />
</div>
<div className="entry-point-buttons">
<div className="entry-point-open">
Expand All @@ -61,6 +66,8 @@ const EmbeddedWorkflowEntryPoint = (props) => {
hasIconOnly
onClick={() => {
setSelectedValue({});
// Ensure the input change is triggered to update form state
input.onChange(null);
}}
/>
</div>
Expand Down
76 changes: 76 additions & 0 deletions app/javascript/components/service-dialog-form/data.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React from 'react';
import {
CheckboxChecked32, RadioButtonChecked32, Time32, StringText32, TextSmallCaps32, CaretDown32, Tag32, Calendar32,
} from '@carbon/icons-react';
import { formattedCatalogPayload } from './helper';

export const dragItems = {
COMPONENT: 'component',
SECTION: 'section',
FIELD: 'field',
TAB: 'tab',
};

/** Data needed to render the dynamic components on the left hand side of the form. */
export const dynamicComponents = [
{ id: 1, title: 'Text Box', icon: <StringText32 /> },
{ id: 2, title: 'Text Area', icon: <TextSmallCaps32 /> },
{ id: 3, title: 'Check Box', icon: <CheckboxChecked32 /> },
{ id: 4, title: 'Dropdown', icon: <CaretDown32 /> },
{ id: 5, title: 'Radio Button', icon: <RadioButtonChecked32 /> },
{ id: 6, title: 'Datepicker', icon: <Calendar32 /> },
{ id: 7, title: 'Timepicker', icon: <Time32 /> },
{ id: 8, title: 'Tag Control', icon: <Tag32 /> },
];

/** Function which returens the default data for a section under a tab. */
export const defaultSectionContents = (tabId, sectionId) => ({
tabId,
sectionId,
title: 'New Section',
fields: [],
order: 0,
});

/** Function which returns the default data for a tab with default section. */
export const defaultTabContents = (tabId) => ({
tabId,
name: tabId === 0 ? __('New Tab') : __(`New Tab ${tabId}`),
sections: [defaultSectionContents(tabId, 0)],
});

/** Function to create a dummy tab for creating new tabs. */
export const createNewTab = () => ({
tabId: 'new',
name: 'Create Tab',
sections: [],
});

export const tagControlCategories = async() => {
try {
const { resources } = await API.get('/api/categories?expand=resources&attributes=id,name,description,single_value,children');

return resources;
} catch (error) {
console.error('Error fetching categories:', error);
return [];
}
};

// data has formfields and list (as of now); no dialog related general info - this is needed
export const saveServiceDialog = (data, onSuccess) => {
const payload = formattedCatalogPayload(data);

API.post('/api/service_dialogs', payload, {
skipErrors: [400],
}).then(() => {
// Redirect to the service dialogs explorer page after successful save
if (typeof onSuccess === 'function') {
onSuccess();
} else {
window.location.href = '/miq_ae_customization/explorer';
}
}).catch((error) => {
console.error('Error saving dialog:', error);
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from 'react';
import PropTypes from 'prop-types';
import { dragItems } from './data';

/** Component to render the components list vertically on left side.
* Components can be used to drag and drop into the tab Contents */
const DynamicComponentChooser = ({ list, onDragStartComponent }) => (
<div className="components-list-wrapper">
{
list.map((item, index) => (
<div
title={`Drag and Drop a ${item.title.toLowerCase()} to any section`}
id={item.id}
className="component-item-wrapper"
draggable="true"
onDragStart={(event) => onDragStartComponent(event, dragItems.COMPONENT)}
key={index.toString()}
>
<div className="component-item">
{item.icon}
{item.title}
</div>
</div>
))
}
</div>
);

DynamicComponentChooser.propTypes = {
list: PropTypes.arrayOf(PropTypes.any).isRequired,
onDragStartComponent: PropTypes.func.isRequired,
};

export default DynamicComponentChooser;
Loading
Loading