From 7a94e93db8140bf7390509346f1b577821083793 Mon Sep 17 00:00:00 2001 From: JEFFREY-Bonson Date: Tue, 30 Jan 2024 20:58:08 +0530 Subject: [PATCH] Method list dialog conversion --- app/controllers/miq_ae_class_controller.rb | 19 +++ .../AeInlineMethod/FilterNamespace.jsx | 56 +++++++ .../AeInlineMethod/NamespaceSelector.jsx | 101 ++++++++++++ .../components/AeInlineMethod/helper.js | 59 +++++++ .../components/AeInlineMethod/index.jsx | 151 ++++++++++++++++++ .../components/AeInlineMethod/style.scss | 74 +++++++++ .../packs/component-definitions-common.js | 2 + .../miq_ae_class/_class_instances.html.haml | 2 + config/routes.rb | 2 + package.json | 2 + 10 files changed, 468 insertions(+) create mode 100644 app/javascript/components/AeInlineMethod/FilterNamespace.jsx create mode 100644 app/javascript/components/AeInlineMethod/NamespaceSelector.jsx create mode 100644 app/javascript/components/AeInlineMethod/helper.js create mode 100644 app/javascript/components/AeInlineMethod/index.jsx create mode 100644 app/javascript/components/AeInlineMethod/style.scss 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",