From 121976e2e38fa19575e378965fdf395add2b772c Mon Sep 17 00:00:00 2001 From: kmalyjur Date: Wed, 12 Feb 2025 10:38:47 +0000 Subject: [PATCH] wip --- .../JobInvocationDetail/CheckboxesActions.js | 182 ++++++++++++++++++ ...nHostTableToolbar.js => DropdownFilter.js} | 9 +- .../JobInvocationConstants.js | 12 ++ .../JobInvocationDetail.scss | 6 + .../JobInvocationHostTable.js | 143 ++++++++++++-- .../JobInvocationToolbarButtons.js | 60 ++++-- .../JobInvocationDetail/OpenAlInvocations.js | 1 + webpack/JobInvocationDetail/index.js | 2 + 8 files changed, 384 insertions(+), 31 deletions(-) create mode 100644 webpack/JobInvocationDetail/CheckboxesActions.js rename webpack/JobInvocationDetail/{JobInvocationHostTableToolbar.js => DropdownFilter.js} (89%) diff --git a/webpack/JobInvocationDetail/CheckboxesActions.js b/webpack/JobInvocationDetail/CheckboxesActions.js new file mode 100644 index 000000000..e870a02fd --- /dev/null +++ b/webpack/JobInvocationDetail/CheckboxesActions.js @@ -0,0 +1,182 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Button, + Dropdown, + DropdownItem, + KebabToggle, +} from '@patternfly/react-core'; +import { useDispatch, useSelector } from 'react-redux'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { addToast } from 'foremanReact/components/ToastsList'; +import { APIActions } from 'foremanReact/redux/API'; +import { selectTaskCancelable } from './JobInvocationSelectors'; +import { hasPermission } from './JobInvocationConstants'; + +export const CheckboxesActions = ({ + jobID, + allHostsIds, + bulkParams, + selectedCount, + isAllSelected, + currentPermissions, + permissionsStatus, +}) => { + const [isOpen, setIsOpen] = React.useState(false); + const isTaskCancelable = useSelector(selectTaskCancelable); + const dispatch = useDispatch(); + + const getIdsArray = () => { + if (!bulkParams) { + return isAllSelected ? allHostsIds : []; + } + // bulkParams in format `id ^ (1,2,3)` + const includeIdsMatch = bulkParams.match(/id \^ \(([^)]+)\)/); + if (includeIdsMatch) { + return includeIdsMatch[1].split(',').map(id => id.trim()); + } + // bulkParams in format `id !^ (1,2,3)` + const excludeIdsMatch = bulkParams.match(/id !\^ \(([^)]+)\)/); + if (excludeIdsMatch) { + const excludedIds = excludeIdsMatch[1] + .split(',') + .map(id => Number(id.trim())); + return allHostsIds.filter(id => !excludedIds.includes(id)); + } + return []; + }; + + const onFocus = () => { + const element = document.getElementById('toggle-kebab'); + element.focus(); + }; + const onSelect = () => { + setIsOpen(false); + onFocus(); + }; + const idsArray = getIdsArray(); + console.log('idsArray ', idsArray); + + const getRerunUrl = () => + idsArray?.length + ? `/job_invocations/${jobID}/rerun?${idsArray + .map(id => `host_ids[]=${id}`) + .join('&')}` + : null; + + const handleTaskAction = action => { + if (idsArray) { + idsArray.forEach(taskID => { + dispatch( + addToast({ + key: `${action}-job-info-${taskID}`, + type: 'info', + message: __(`Trying to ${action} the task for the host`), + }) + ); + + dispatch( + APIActions.post({ + url: `/foreman_tasks/tasks/${taskID}/${action}`, + key: `${action.toUpperCase()}_TASK_${taskID}`, + errorToast: ({ responseError }) => responseError.data.message, + successToast: () => + action === 'cancel' + ? __(`Task for the host cancelled successfully`) + : __(`Task for the host aborted successfully`), + }) + ); + }); + } + }; + + const RerunButton = () => ( + + ); + + const dropdownItems = [ + handleTaskAction('cancel')} + key="cancel" + component="button" + isDisabled={ + selectedCount === 0 || + !isTaskCancelable || + !hasPermission( + currentPermissions, + permissionsStatus, + 'cancel_job_invocations' + ) + } + > + {__('Cancel')} + , + handleTaskAction('abort')} + key="abort" + component="button" + isDisabled={ + selectedCount === 0 || + !isTaskCancelable || + !hasPermission( + currentPermissions, + permissionsStatus, + 'cancel_job_invocations' + ) + } + > + {__('Abort')} + , + ]; + + const ActionsKebab = () => ( + } + isOpen={isOpen} + isPlain + dropdownItems={dropdownItems} + /> + ); + + return ( + <> + + + + ); +}; + +CheckboxesActions.propTypes = { + jobID: PropTypes.string.isRequired, + allHostsIds: PropTypes.array.isRequired, + bulkParams: PropTypes.string, + selectedCount: PropTypes.number.isRequired, + isAllSelected: PropTypes.bool.isRequired, + currentPermissions: PropTypes.array, + permissionsStatus: PropTypes.string, +}; + +CheckboxesActions.defaultProps = { + bulkParams: undefined, + currentPermissions: [], + permissionsStatus: undefined, +}; diff --git a/webpack/JobInvocationDetail/JobInvocationHostTableToolbar.js b/webpack/JobInvocationDetail/DropdownFilter.js similarity index 89% rename from webpack/JobInvocationDetail/JobInvocationHostTableToolbar.js rename to webpack/JobInvocationDetail/DropdownFilter.js index 8b3601c2c..ed4012c9d 100644 --- a/webpack/JobInvocationDetail/JobInvocationHostTableToolbar.js +++ b/webpack/JobInvocationDetail/DropdownFilter.js @@ -5,10 +5,7 @@ import { Select, SelectOption, SelectList } from '@patternfly/react-core/next'; import { MenuToggle, ToolbarItem } from '@patternfly/react-core'; import { STATUS_TITLES } from './JobInvocationConstants'; -const JobInvocationHostTableToolbar = ({ - dropdownFilter, - setDropdownFilter, -}) => { +const DropdownFilter = ({ dropdownFilter, setDropdownFilter }) => { const [isOpen, setIsOpen] = React.useState(false); const onSelect = (_event, itemId) => { setDropdownFilter(itemId); @@ -55,9 +52,9 @@ const JobInvocationHostTableToolbar = ({ ); }; -JobInvocationHostTableToolbar.propTypes = { +DropdownFilter.propTypes = { dropdownFilter: PropTypes.string.isRequired, setDropdownFilter: PropTypes.func.isRequired, }; -export default JobInvocationHostTableToolbar; +export default DropdownFilter; diff --git a/webpack/JobInvocationDetail/JobInvocationConstants.js b/webpack/JobInvocationDetail/JobInvocationConstants.js index 1c997b89d..bd4a2ab2a 100644 --- a/webpack/JobInvocationDetail/JobInvocationConstants.js +++ b/webpack/JobInvocationDetail/JobInvocationConstants.js @@ -3,6 +3,7 @@ import React from 'react'; import { foremanUrl } from 'foremanReact/common/helpers'; import { translate as __ } from 'foremanReact/common/I18n'; import { useForemanHostDetailsPageUrl } from 'foremanReact/Root/Context/ForemanContext'; +import { STATUS as APIStatus } from 'foremanReact/constants'; import JobStatusIcon from '../react_app/components/RecentJobsCard/JobStatusIcon'; export const JOB_INVOCATION_KEY = 'JOB_INVOCATION_KEY'; @@ -60,6 +61,17 @@ export const DATE_OPTIONS = { timeZoneName: 'short', }; +export const hasPermission = ( + currentPermissions, + permissionsStatus, + permissionRequired +) => + permissionsStatus === APIStatus.RESOLVED + ? currentPermissions?.some( + permission => permission.name === permissionRequired + ) + : false; + const Columns = () => { const getColumnsStatus = ({ hostJobStatus }) => { switch (hostJobStatus) { diff --git a/webpack/JobInvocationDetail/JobInvocationDetail.scss b/webpack/JobInvocationDetail/JobInvocationDetail.scss index 516fbce9c..1f05fb156 100644 --- a/webpack/JobInvocationDetail/JobInvocationDetail.scss +++ b/webpack/JobInvocationDetail/JobInvocationDetail.scss @@ -38,14 +38,20 @@ height: $chart_size; } } + .job-additional-info { padding: 0; margin-bottom: -10px; } + .job-details-table-section { section:nth-child(1) { padding: 0; } + .open-all-button { + margin-left: 10px; + margin-right: 20px; + } } .template-invocation { diff --git a/webpack/JobInvocationDetail/JobInvocationHostTable.js b/webpack/JobInvocationDetail/JobInvocationHostTable.js index dd3cbe8b0..0d08fe245 100644 --- a/webpack/JobInvocationDetail/JobInvocationHostTable.js +++ b/webpack/JobInvocationDetail/JobInvocationHostTable.js @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ /* eslint-disable camelcase */ import PropTypes from 'prop-types'; import React, { useMemo, useEffect, useState } from 'react'; @@ -6,6 +7,7 @@ import { translate as __ } from 'foremanReact/common/I18n'; import { FormattedMessage } from 'react-intl'; import { Tr, Td, Tbody, ExpandableRowContent } from '@patternfly/react-table'; import { + ToolbarItem, Title, EmptyState, EmptyStateVariant, @@ -20,7 +22,10 @@ import { useBulkSelect, useUrlParams, } from 'foremanReact/components/PF4/TableIndexPage/Table/TableHooks'; +import { RowSelectTd } from 'foremanReact/components/HostsIndex/RowSelectTd'; import { getControllerSearchProps } from 'foremanReact/constants'; +import SelectAllCheckbox from 'foremanReact/components/PF4/TableIndexPage/Table/SelectAllCheckbox'; +import { getPageStats } from 'foremanReact/components/PF4/TableIndexPage/Table/helpers'; import Columns, { JOB_INVOCATION_HOSTS, STATUS_UPPERCASE, @@ -28,7 +33,8 @@ import Columns, { import { TemplateInvocation } from './TemplateInvocation'; import { OpenAlInvocations, PopupAlert } from './OpenAlInvocations'; import { RowActions } from './TemplateInvocationComponents/TemplateActionButtons'; -import JobInvocationHostTableToolbar from './JobInvocationHostTableToolbar'; +import DropdownFilter from './DropdownFilter'; +import { CheckboxesActions } from './CheckboxesActions'; const JobInvocationHostTable = ({ id, @@ -36,6 +42,8 @@ const JobInvocationHostTable = ({ finished, autoRefresh, initialFilter, + currentPermissions, + permissionsStatus, }) => { const columns = Columns(); const columnNamesKeys = Object.keys(columns); @@ -74,18 +82,73 @@ const JobInvocationHostTable = ({ } ); + const [allPagesResponse, setAllPagesResponse] = useState([]); + + const { response: allResponse, setAPIOptions: setAllAPIOptions } = useAPI( + 'get', + `/api/job_invocations/${id}/hosts`, + { + params: { + page: 1, + per_page: response?.subtotal, // Default to 20 to avoid undefined + search: constructFilter(selectedFilter, urlSearchQuery), + }, + } + ); + + // Update API request only when needed (subtotal or filters change) + useEffect(() => { + if (response?.subtotal) { + setAllAPIOptions(prevOptions => ({ + ...prevOptions, + params: { + page: 1, + per_page: response.subtotal, // Fetch all results in one call + search: constructFilter(selectedFilter, urlSearchQuery), + }, + })); + } + }, [response?.subtotal, selectedFilter, urlSearchQuery]); // **Only re-run when subtotal or filters change** + + // Update local state only when the API response updates + useEffect(() => { + if (allResponse?.results) { + setAllPagesResponse(allResponse.results); + } + }, [allResponse]); // **Separate state update from API request trigger** + + console.log('All Filtered Data:', allPagesResponse); + const { params } = useSetParamsAndApiAndSearch({ defaultParams, apiOptions, setAPIOptions, }); - const { updateSearchQuery: updateSearchQueryBulk } = useBulkSelect({ + const { + updateSearchQuery: updateSearchQueryBulk, + fetchBulkParams, + ...selectAllOptions + } = useBulkSelect({ + results: response?.results, + metadata: { + total: response?.total, + page: response?.page, + selectable: response?.subtotal, + }, initialSearchQuery: urlSearchQuery, }); - const updateSearchQuery = searchQuery => { - updateSearchQueryBulk(searchQuery); - }; + + const { + selectAll, + selectPage, + selectNone, + selectedCount, + selectOne, + areAllRowsOnPageSelected, + areAllRowsSelected, + isSelected, + } = selectAllOptions; const controller = 'hosts'; const memoDefaultSearchProps = useMemo( @@ -98,6 +161,7 @@ const JobInvocationHostTable = ({ const wrapSetSelectedFilter = filter => { const filterSearch = constructFilter(filter); + console.log('wrapSetSelectedFilter'); setAPIOptions(prevOptions => { if (prevOptions.params.search !== filterSearch) { return { @@ -115,6 +179,7 @@ const JobInvocationHostTable = ({ useEffect(() => { const intervalId = setInterval(() => { + console.log('USE EFFECT'); if (!finished || autoRefresh) { setAPIOptions(prevOptions => ({ ...prevOptions, @@ -156,6 +221,35 @@ const JobInvocationHostTable = ({ setAPIOptions: wrapSetAPIOptions, }; + const { pageRowCount } = getPageStats({ + total: response?.total || 0, + page: response?.page || urlPage || 1, + perPage: response?.perPage || urlPerPage || 20, + }); + const selectionToolbar = ( + + + + ); + + console.log('------------------------------------'); + console.log('selectedCount ', selectedCount); + console.log('areAllRowsSelected ', areAllRowsSelected()); + console.log('areAllRowsOnPageSelected ', areAllRowsOnPageSelected()); + + const bulkParams = selectedCount ? fetchBulkParams() : null; + const customEmptyState = ( @@ -211,18 +305,33 @@ const JobInvocationHostTable = ({ controller="hosts" creatable={false} replacementResponse={combinedResponse} - updateSearchQuery={updateSearchQuery} + updateSearchQuery={updateSearchQueryBulk} customToolbarItems={[ + , , - item.id) || []} + subtotal={response?.subtotal} + bulkParams={bulkParams} + selectedCount={selectedCount} + isAllSelected={areAllRowsSelected()} + currentPermissions={currentPermissions} + permissionsStatus={permissionsStatus} + filter={constructFilter()} />, ]} + selectionToolbar={selectionToolbar} > {}} errorMessage={ status === STATUS_UPPERCASE.ERROR && response?.message @@ -247,7 +357,7 @@ const JobInvocationHostTable = ({ isDeleteable={false} childrenOutsideTbody > - {results?.map((result, rowIndex) => ( + {response?.results?.map((result, rowIndex) => ( ))} @@ -300,8 +416,13 @@ JobInvocationHostTable.propTypes = { finished: PropTypes.bool.isRequired, autoRefresh: PropTypes.bool.isRequired, initialFilter: PropTypes.string.isRequired, + currentPermissions: PropTypes.array, + permissionsStatus: PropTypes.string, }; -JobInvocationHostTable.defaultProps = {}; +JobInvocationHostTable.defaultProps = { + currentPermissions: undefined, + permissionsStatus: undefined, +}; export default JobInvocationHostTable; diff --git a/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js b/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js index 02e838c70..4ebc79724 100644 --- a/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js +++ b/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js @@ -13,7 +13,6 @@ import { } from '@patternfly/react-core'; import { translate as __ } from 'foremanReact/common/I18n'; import { foremanUrl } from 'foremanReact/common/helpers'; -import { STATUS as APIStatus } from 'foremanReact/constants'; import { get } from 'foremanReact/redux/API'; import { cancelJob, @@ -24,6 +23,7 @@ import { STATUS, GET_REPORT_TEMPLATES, GET_REPORT_TEMPLATE_INPUTS, + hasPermission, } from './JobInvocationConstants'; import { selectTaskCancelable } from './JobInvocationSelectors'; @@ -60,12 +60,6 @@ const JobInvocationToolbarButtons = ({ setIsActionOpen(false); onActionFocus(); }; - const hasPermission = permissionRequired => - permissionsStatus === APIStatus.RESOLVED - ? currentPermissions?.some( - permission => permission.name === permissionRequired - ) - : false; useEffect(() => { dispatch( @@ -144,7 +138,14 @@ const JobInvocationToolbarButtons = ({ ouiaId="rerun-succeeded-dropdown-item" href={foremanUrl(`/job_invocations/${jobId}/rerun?succeeded_only=1`)} key="rerun-succeeded" - isDisabled={!(succeeded > 0) || !hasPermission('create_job_invocations')} + isDisabled={ + !(succeeded > 0) || + !hasPermission( + currentPermissions, + permissionsStatus, + 'create_job_invocations' + ) + } description="Rerun job on successful hosts" > {__('Rerun successful')} @@ -153,7 +154,14 @@ const JobInvocationToolbarButtons = ({ ouiaId="rerun-failed-dropdown-item" href={foremanUrl(`/job_invocations/${jobId}/rerun?failed_only=1`)} key="rerun-failed" - isDisabled={!(failed > 0) || !hasPermission('create_job_invocations')} + isDisabled={ + !(failed > 0) || + !hasPermission( + currentPermissions, + permissionsStatus, + 'create_job_invocations' + ) + } description="Rerun job on failed hosts" > {__('Rerun failed')} @@ -173,7 +181,14 @@ const JobInvocationToolbarButtons = ({ onClick={() => dispatch(cancelJob(jobId, false))} key="cancel" component="button" - isDisabled={!isTaskCancelable || !hasPermission('cancel_job_invocations')} + isDisabled={ + !isTaskCancelable || + !hasPermission( + currentPermissions, + permissionsStatus, + 'cancel_job_invocations' + ) + } description="Cancel job gracefully" > {__('Cancel')} @@ -183,7 +198,14 @@ const JobInvocationToolbarButtons = ({ onClick={() => dispatch(cancelJob(jobId, true))} key="abort" component="button" - isDisabled={!isTaskCancelable || !hasPermission('cancel_job_invocations')} + isDisabled={ + !isTaskCancelable || + !hasPermission( + currentPermissions, + permissionsStatus, + 'cancel_job_invocations' + ) + } description="Cancel job immediately" > {__('Abort')} @@ -214,7 +236,11 @@ const JobInvocationToolbarButtons = ({ isDisabled={ task?.state === STATUS.PENDING || templateInputId === undefined || - !hasPermission('generate_report_templates') + !hasPermission( + currentPermissions, + permissionsStatus, + 'generate_report_templates' + ) } > {__(`Create report`)} @@ -236,7 +262,13 @@ const JobInvocationToolbarButtons = ({ key="rerun" href={foremanUrl(`/job_invocations/${jobId}/rerun`)} variant="control" - isDisabled={!hasPermission('create_job_invocations')} + isDisabled={ + !hasPermission( + currentPermissions, + permissionsStatus, + 'create_job_invocations' + ) + } > {__(`Rerun all`)} , @@ -261,7 +293,7 @@ JobInvocationToolbarButtons.propTypes = { permissionsStatus: PropTypes.string, }; JobInvocationToolbarButtons.defaultProps = { - currentPermissions: undefined, + currentPermissions: [], permissionsStatus: undefined, }; diff --git a/webpack/JobInvocationDetail/OpenAlInvocations.js b/webpack/JobInvocationDetail/OpenAlInvocations.js index 33f65fb40..a0df06374 100644 --- a/webpack/JobInvocationDetail/OpenAlInvocations.js +++ b/webpack/JobInvocationDetail/OpenAlInvocations.js @@ -40,6 +40,7 @@ export const OpenAlInvocations = ({ results, id, setShowAlert }) => { isInline aria-label="open all template invocations in a new tab" ouiaId="template-invocation-new-tab-button" + className="open-all-button" onClick={() => { if (results.length <= 3) { results.forEach(result => { diff --git a/webpack/JobInvocationDetail/index.js b/webpack/JobInvocationDetail/index.js index 0cf77ac62..dc28a7fab 100644 --- a/webpack/JobInvocationDetail/index.js +++ b/webpack/JobInvocationDetail/index.js @@ -154,6 +154,8 @@ const JobInvocationDetailPage = ({ finished={finished} autoRefresh={autoRefresh} initialFilter={selectedFilter} + currentPermissions={response.results} + permissionsStatus={status} /> )}
+ { + + } {columnNamesKeys.slice(1).map(k => ( {columns[k].wrapper(result)}