diff --git a/app/controllers/miq_ae_class_controller.rb b/app/controllers/miq_ae_class_controller.rb
index d13ce6bb0096..d806e08c9ab4 100644
--- a/app/controllers/miq_ae_class_controller.rb
+++ b/app/controllers/miq_ae_class_controller.rb
@@ -1815,6 +1815,25 @@ def namespace
render :json => find_record_with_rbac(MiqAeNamespace, params[:id]).attributes.slice('name', 'description', 'enabled')
end
+ def ae_domains
+ domains = MiqAeDomain.where("ancestry is null and enabled = ?", true).order("name").select("id, name")
+ render :json => {:domains => domains}
+ end
+
+ def ae_methods
+ methods = MiqAeMethod
+ .name_path_search(params[:search])
+ .domain_search(params[:domain_id])
+ .selected_methods(params[:ids])
+ .select("id, relative_path, name")
+ .order('name')
+ # methods = MiqAeMethod.all.order('name').select("id, relative_path, name")
+ # methods = methods.where('name ILIKE ? or relative_path ILIKE ?', "%#{params[:search]}%", "%#{params[:search]}%") if params[:search]
+ # methods = methods.where('domain_id = ?', params[:domain_id]) if params[:domain_id]
+ # methods = methods.where(id: params[:ids].split(',').map(&:to_i)) if params[:ids]
+ render :json => {:methods => methods}
+ end
+
private
def feature_by_action
diff --git a/app/javascript/components/AeInlineMethod/FilterNamespace.jsx b/app/javascript/components/AeInlineMethod/FilterNamespace.jsx
new file mode 100644
index 000000000000..70fb145ae9c0
--- /dev/null
+++ b/app/javascript/components/AeInlineMethod/FilterNamespace.jsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+ Select, SelectItem, Search,
+} from 'carbon-components-react';
+import { noSelect } from './helper';
+
+const FilterNamespace = ({ domains, onSearch }) => {
+ /** Function to render the search text. */
+ const renderSearchText = () => (
+
+
+ onSearch({ searchText: noSelect })}
+ onChange={(event) => onSearch({ searchText: event.target.value || noSelect })}
+ />
+
+ );
+
+ /** Function to render the domain items in a drop-down list. */
+ const renderDomainList = () => (
+
+ );
+
+ return (
+
+ {renderSearchText()}
+ {domains && renderDomainList()}
+
+ );
+};
+
+export default FilterNamespace;
+
+FilterNamespace.propTypes = {
+ domains: PropTypes.arrayOf(PropTypes.any),
+ onSearch: PropTypes.func.isRequired,
+};
+
+FilterNamespace.defaultProps = {
+ domains: undefined,
+};
diff --git a/app/javascript/components/AeInlineMethod/NamespaceSelector.jsx b/app/javascript/components/AeInlineMethod/NamespaceSelector.jsx
new file mode 100644
index 000000000000..3b8337229ff9
--- /dev/null
+++ b/app/javascript/components/AeInlineMethod/NamespaceSelector.jsx
@@ -0,0 +1,101 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { useQuery } from 'react-query';
+import { Loading } from 'carbon-components-react';
+import { debounce } from 'lodash';
+import FilterNamespace from './FilterNamespace';
+import MiqDataTable from '../miq-data-table';
+import NotificationMessage from '../notification-message';
+import { CellAction } from '../miq-data-table/helper';
+import {
+ methodSelectorHeaders, formatMethods, searchUrl, namespaceUrls,
+} from './helper';
+import './style.scss';
+
+const NamespaceSelector = ({ onSelectMethod, selectedIds }) => {
+ const [filterData, setFilterData] = useState({ searchText: '', selectedDomain: '' });
+
+ /** Loads the domains and stores in domainData for 60 seconds. */
+ const { data: domainsData, isLoading: domainsLoading } = useQuery(
+ 'domainsData',
+ async() => (await http.get(namespaceUrls.aeDomainsUrl)).domains,
+ {
+ staleTime: 60000,
+ }
+ );
+
+ /** Loads the methods and stores in methodsData for 60 seconds.
+ * If condition works on page load
+ * Else part would work if there is a change in filterData.
+ */
+ const { data, isLoading: methodsLoading } = useQuery(
+ ['methodsData', filterData.searchText, filterData.selectedDomain],
+ async() => {
+ if (!filterData.searchText && !filterData.selectedDomain) {
+ const response = await http.get(namespaceUrls.aeMethodsUrl);
+ return formatMethods(response.methods);
+ }
+ const url = searchUrl(filterData.selectedDomain, filterData.searchText);
+ const response = await http.get(url);
+ return formatMethods(response.methods);
+ },
+ {
+ keepPreviousData: true,
+ refetchOnWindowFocus: false,
+ staleTime: 60000,
+ }
+ );
+
+ /** Debounce the search text by delaying the text input provided to the API. */
+ const debouncedSearch = debounce((newFilterData) => {
+ setFilterData(newFilterData);
+ }, 300);
+
+ /** Function to handle the onSearch event during a filter change event. */
+ const onSearch = (newFilterData) => debouncedSearch(newFilterData);
+
+ /** Function to handle the click event of a cell in the data table. */
+ const onCellClick = (selectedRow, cellType, checked) => {
+ const selectedItems = cellType === CellAction.selectAll
+ ? data && data.map((item) => item.id)
+ : [selectedRow];
+ onSelectMethod({ selectedItems, cellType, checked });
+ };
+
+ const renderContents = () => {
+ if (!data || data.length === 0) {
+ return ;
+ }
+
+ return (
+ onCellClick(selectedRow, cellType, event.target.checked)}
+ />
+ );
+ };
+
+ return (
+
+
+
+ {(domainsLoading || methodsLoading)
+ ?
+ : renderContents()}
+
+
+ );
+};
+
+NamespaceSelector.propTypes = {
+ onSelectMethod: PropTypes.func.isRequired,
+ selectedIds: PropTypes.arrayOf(PropTypes.any).isRequired,
+};
+
+export default NamespaceSelector;
diff --git a/app/javascript/components/AeInlineMethod/helper.js b/app/javascript/components/AeInlineMethod/helper.js
new file mode 100644
index 000000000000..8db2f338e414
--- /dev/null
+++ b/app/javascript/components/AeInlineMethod/helper.js
@@ -0,0 +1,59 @@
+export const namespaceUrls = {
+ aeMethodsUrl: '/miq_ae_class/ae_methods',
+ aeDomainsUrl: '/miq_ae_class/ae_domains',
+};
+
+export const noSelect = 'NONE';
+
+/** Headers needed for the data-table list. */
+export const methodSelectorHeaders = [
+ {
+ key: 'name',
+ header: 'Name',
+ },
+ {
+ key: 'path',
+ header: 'Relative path',
+ },
+];
+
+export const methodListHeaders = [
+ ...methodSelectorHeaders,
+ { key: 'delete', header: __('Delete') },
+];
+
+/** Function to format the method data needed for the data-table list. */
+export const formatMethods = (methods) => (methods.map((item) => ({
+ id: item.id.toString(),
+ name: { text: item.name, icon: 'icon node-icon fa-ruby' },
+ path: item.relative_path,
+})));
+
+const deleteMethodButton = () => ({
+ is_button: true,
+ title: __('Delete'),
+ text: __('Delete'),
+ alt: __('Delete'),
+ kind: 'ghost',
+ callback: 'removeMethod',
+});
+
+export const formatListMethods = (methods) => (methods.map((item, index) => ({
+ id: item.id.toString(),
+ name: { text: item.name, icon: 'icon node-icon fa-ruby' },
+ path: item.relative_path,
+ delete: deleteMethodButton(item, index),
+})));
+
+/** Function to return a conditional URL based on the selected filters. */
+export const searchUrl = (selectedDomain, text) => {
+ const queryParams = [];
+ if (selectedDomain && selectedDomain !== noSelect) {
+ queryParams.push(`domain_id=${selectedDomain}`);
+ }
+ if (text && text !== noSelect) {
+ queryParams.push(`search=${text}`);
+ }
+ const queryString = queryParams.length > 0 ? `?${queryParams.join('&')}` : '';
+ return `${namespaceUrls.aeMethodsUrl}${queryString}`;
+};
diff --git a/app/javascript/components/AeInlineMethod/index.jsx b/app/javascript/components/AeInlineMethod/index.jsx
new file mode 100644
index 000000000000..05a152b1a097
--- /dev/null
+++ b/app/javascript/components/AeInlineMethod/index.jsx
@@ -0,0 +1,151 @@
+import React, { useState } from 'react';
+import { QueryClient, QueryClientProvider } from 'react-query';
+import PropTypes from 'prop-types';
+import {
+ Modal, Button, ModalBody, Accordion, AccordionItem,
+} from 'carbon-components-react';
+import { AddAlt16 } from '@carbon/icons-react';
+import NotificationMessage from '../notification-message';
+import MiqDataTable from '../miq-data-table';
+import NamespaceSelector from './NamespaceSelector';
+import { CellAction } from '../miq-data-table/helper';
+import { formatListMethods, methodListHeaders, namespaceUrls } from './helper';
+
+/** Component to render a tree and to select an embedded method. */
+const AeInlineMethod = ({ type }) => {
+ const queryClient = new QueryClient();
+
+ const [data, setData] = useState({
+ isModalOpen: false,
+ selectedIds: [],
+ rows: [],
+ });
+
+ /** Function to show/hide the modal. */
+ const showModal = (status) => setData({ ...data, isModalOpen: status });
+
+ /** Function to handle the select-all check-box click event. */
+ const onSelectAll = (selectedItems, checked) => setData({ ...data, selectedIds: checked ? [...selectedItems] : [] });
+
+ /** Function to handle the list row selection events.
+ * selectedItem is passed as an array. */
+ const onItemSelect = (selectedItems, checked) => {
+ if (checked) {
+ data.selectedIds.push(selectedItems[0].id);
+ } else {
+ data.selectedIds = data.selectedIds.filter((item) => item !== selectedItems[0].id);
+ }
+ setData({ ...data, selectedIds: [...data.selectedIds] });
+ };
+
+ /** Function to add/remove an selected items. */
+ const onSelectMethod = (selectedItems, cellType, checked) => {
+ switch (cellType) {
+ case CellAction.selectAll: onSelectAll(selectedItems, checked); break;
+ default: onItemSelect(selectedItems, checked); break;
+ }
+ };
+
+ /** Function to handle the click events for the list. */
+ const onCellClickHandler = (item) => {
+ if (item && item.callbackAction && item.callbackAction === 'removeMethod') {
+ setData({
+ rows: data.rows.filter((row) => row.id !== item.id),
+ selectedIds: data.selectedIds.filter((id) => id !== item.id),
+ });
+ }
+ };
+
+ /** Function to handle the modal submit action. */
+ const submitModal = () => {
+ if (data.selectedIds.length > 0) {
+ http.get(`${namespaceUrls.aeMethodsUrl}?ids=${data.selectedIds.map((str) => parseInt(str, 10))}`)
+ .then(({ methods }) => setData({ ...data, rows: formatListMethods(methods), isModalOpen: false }));
+ } else {
+ setData({ ...data, rows: [], isModalOpen: false });
+ }
+ };
+
+ /** Function to render the modal with namespace selector component. */
+ const renderModalSelector = () => (
+ showModal(false)}
+ onRequestSubmit={() => submitModal()}
+ onSecondarySubmit={() => showModal(false)}
+ >
+
+ {
+ data.isModalOpen
+ && (
+
+ onSelectMethod(selectedItems, cellType, checked)}
+ selectedIds={data.selectedIds}
+ />
+
+ )
+ }
+
+
+ );
+
+ /** Function to render the contents of the list. */
+ const renderList = () => (data.rows && data.rows.length > 0
+ ? (
+ onCellClickHandler(selectedRow)}
+ />
+ )
+ : (
+
+
+
+ ));
+
+ const renderAddButton = () => (
+
+
+
+ );
+
+ const renderCustomContents = () => (
+
+
+ {renderAddButton()}
+ {renderList()}
+
+
+ );
+
+ return (
+
+ {renderCustomContents()}
+ {renderModalSelector()}
+
+ );
+};
+
+export default AeInlineMethod;
+
+AeInlineMethod.propTypes = {
+ type: PropTypes.string.isRequired,
+};
diff --git a/app/javascript/components/AeInlineMethod/style.scss b/app/javascript/components/AeInlineMethod/style.scss
new file mode 100644
index 000000000000..3b34f7fec309
--- /dev/null
+++ b/app/javascript/components/AeInlineMethod/style.scss
@@ -0,0 +1,74 @@
+.ae-inline-method-modal {
+ .bx--modal-content {
+ margin-bottom: 0;
+ }
+
+ .inline-method-selector {
+ display: flex;
+ flex-direction: column;
+ min-height: 520px;
+
+ .inline-filters {
+ display: flex;
+ flex-direction: row;
+ align-items: end;
+ gap: 10px;
+
+ .search-wrapper {
+ display: flex;
+ flex-grow: 1;
+ flex-direction: column;
+ align-items: flex-start;
+ }
+ }
+
+ .inline-contents-wrapper {
+ display: flex;
+ flex-direction: column;
+ margin-top: 20px;
+
+ .miq-inline-method-list {
+ margin-top: 0;
+
+ .bx--data-table-content {
+ margin-bottom: 0;
+
+ .bx--data-table--sticky-header {
+ max-height: 25rem;
+ }
+ }
+ }
+
+ .miq-notification-message-container {
+ margin: 0;
+ }
+ }
+ }
+}
+
+
+.miq-custom-form-accordion
+{
+ border: 1px solid #e0e0e0;
+
+ li button.bx--accordion__heading {
+ background: #e0e0e0;
+ }
+ .bx--accordion__item:last-child{
+ border: 0;
+ }
+
+ .bx--accordion__content {
+ padding: 20px;
+ margin: 0;
+
+ .custom-form-buttons {
+ display: flex;
+ justify-content: flex-end;
+ }
+
+ .ae-inline-methods-notification {
+ margin-top: 20px;
+ }
+ }
+}
diff --git a/app/javascript/packs/component-definitions-common.js b/app/javascript/packs/component-definitions-common.js
index 1f401395769f..79728790a692 100644
--- a/app/javascript/packs/component-definitions-common.js
+++ b/app/javascript/packs/component-definitions-common.js
@@ -11,6 +11,7 @@ import { Toolbar } from '../components/toolbar';
import ActionForm from '../components/action-form';
import AddRemoveHostAggregateForm from '../components/host-aggregate-form/add-remove-host-aggregate-form';
import AddRemoveSecurityGroupForm from '../components/vm-cloud-add-remove-security-group-form';
+import AeInlineMethod from '../components/AeInlineMethod';
import AggregateStatusCard from '../components/aggregate_status_card';
import AnsibleCredentialsForm from '../components/ansible-credentials-form';
import AnsiblePlayBookEditCatalogForm from '../components/ansible-playbook-edit-catalog-form';
@@ -185,6 +186,7 @@ ManageIQ.component.addReact('ActionForm', ActionForm);
ManageIQ.component.addReact('AddRemoveHostAggregateForm', AddRemoveHostAggregateForm);
ManageIQ.component.addReact('AddRemoveSecurityGroupForm', AddRemoveSecurityGroupForm);
ManageIQ.component.addReact('AggregateStatusCard', AggregateStatusCard);
+ManageIQ.component.addReact('AeInlineMethod', AeInlineMethod);
ManageIQ.component.addReact('AnsibleCredentialsForm', AnsibleCredentialsForm);
ManageIQ.component.addReact('AnsiblePlayBookEditCatalogForm', AnsiblePlayBookEditCatalogForm);
ManageIQ.component.addReact('AnsiblePlaybookWorkflow', AnsiblePlaybookWorkflow);
diff --git a/app/views/miq_ae_class/_class_instances.html.haml b/app/views/miq_ae_class/_class_instances.html.haml
index c4b0b3e0ebae..57a9b2042622 100644
--- a/app/views/miq_ae_class/_class_instances.html.haml
+++ b/app/views/miq_ae_class/_class_instances.html.haml
@@ -1,3 +1,5 @@
+= react('AeInlineMethod', {:type => "aeInlineMethod"})
+
#class_instances_div
- unless @angular_form
- if !@in_a_form
diff --git a/config/routes.rb b/config/routes.rb
index 7a9b745a340f..30ca56de5a1e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1906,6 +1906,8 @@
explorer
method_form_fields
namespace
+ ae_domains
+ ae_methods
show
],
:post => %w[
diff --git a/package.json b/package.json
index 2f12a67e0d3f..0ac5c314af84 100644
--- a/package.json
+++ b/package.json
@@ -76,6 +76,7 @@
"jquery-ujs": "~1.2.2",
"jquery.hotkeys": "~0.1.0",
"jquery.observe_field": "~0.1.0",
+ "loadash": "^1.0.0",
"lodash": "~4.17.10",
"moment": "~2.29.4",
"moment-duration-format": "~2.2.2",
@@ -90,6 +91,7 @@
"react-codemirror2": "^6.0.0",
"react-dom": "~16.13.1",
"react-markdown": "6.0.0",
+ "react-query": "^3.39.3",
"react-redux": "^7.1.1",
"react-router": "~5.1.2",
"react-router-dom": "~5.1.2",