Revision b12280a7
Added by Jeremy Lenz about 1 month ago
app/lib/actions/katello/host/update_content_view.rb | ||
---|---|---|
|
||
def humanized_name
|
||
if input.try(:[], :hostname).nil?
|
||
_("Update for host")
|
||
_("Update content view environments for host")
|
||
else
|
||
_("Update for host %s") % input[:hostname]
|
||
_("Update content view environments for host %s") % input[:hostname]
|
||
end
|
||
end
|
||
end
|
webpack/components/extensions/Hosts/ActionsBar/index.js | ||
---|---|---|
import React, { useContext } from 'react';
|
||
import React, { useContext, useEffect } from 'react';
|
||
import { useDispatch } from 'react-redux';
|
||
import { DropdownItem } from '@patternfly/react-core';
|
||
import { translate as __ } from 'foremanReact/common/I18n';
|
||
import { foremanUrl } from 'foremanReact/common/helpers';
|
||
import { ForemanHostsIndexActionsBarContext } from 'foremanReact/components/HostsIndex';
|
||
import { useForemanModal } from 'foremanReact/components/ForemanModal/ForemanModalHooks';
|
||
import { addModal } from 'foremanReact/components/ForemanModal/ForemanModalActions';
|
||
|
||
const HostActionsBar = () => {
|
||
const {
|
||
... | ... | |
selectAllMode,
|
||
} = useContext(ForemanHostsIndexActionsBarContext);
|
||
|
||
const dispatch = useDispatch();
|
||
useEffect(() => {
|
||
dispatch(addModal({
|
||
id: 'bulk-change-cv-modal',
|
||
}));
|
||
}, [dispatch]);
|
||
const { setModalOpen } = useForemanModal({ id: 'bulk-change-cv-modal' });
|
||
|
||
let href = '';
|
||
if (selectAllMode) {
|
||
const query = fetchBulkParams({ selectAllQuery: 'created_at < "1 second ago"' });
|
||
... | ... | |
>
|
||
{__('Change content source')}
|
||
</DropdownItem>
|
||
<DropdownItem
|
||
ouiaId="bulk-change-cv-dropdown-item"
|
||
key="bulk-change-cv-dropdown-item"
|
||
onClick={setModalOpen}
|
||
isDisabled={selectedCount === 0}
|
||
>
|
||
{__('Change content view environments')}
|
||
</DropdownItem>
|
||
</>
|
||
);
|
||
};
|
webpack/components/extensions/Hosts/BulkActions/BulkChangeHostCVModal/BulkChangeHostCVModal.js | ||
---|---|---|
import React, { useState } from 'react';
|
||
import PropTypes from 'prop-types';
|
||
import { useDispatch, useSelector } from 'react-redux';
|
||
import { FormattedMessage } from 'react-intl';
|
||
import { Modal, Button, Alert, TextContent, Text, TextVariants } from '@patternfly/react-core';
|
||
import { translate as __ } from 'foremanReact/common/I18n';
|
||
import { STATUS } from 'foremanReact/constants';
|
||
import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
|
||
import { selectAPIStatus } from 'foremanReact/redux/API/APISelectors';
|
||
import { ENVIRONMENT_PATHS_KEY } from '../../../../../scenes/ContentViews/components/EnvironmentPaths/EnvironmentPathConstants';
|
||
import EnvironmentPaths from '../../../../../scenes/ContentViews/components/EnvironmentPaths/EnvironmentPaths';
|
||
import ContentViewSelect from '../../../../../scenes/ContentViews/components/ContentViewSelect/ContentViewSelect';
|
||
import ContentViewSelectOption from '../../../../../scenes/ContentViews/components/ContentViewSelect/ContentViewSelectOption';
|
||
import api from '../../../../../services/api';
|
||
import getContentViews from '../../../../../scenes/ContentViews/ContentViewsActions';
|
||
import { selectContentViews, selectContentViewStatus } from '../../../../../scenes/ContentViews/ContentViewSelectors';
|
||
import { bulkUpdateHostContentViewAndEnvironment } from './actions';
|
||
import { getCVPlaceholderText } from '../../../../../scenes/ContentViews/components/ContentViewSelect/helpers';
|
||
import HOST_CV_AND_ENV_KEY from '../../../HostDetails/Cards/ContentViewDetailsCard/HostContentViewConstants';
|
||
|
||
const ENV_PATH_OPTIONS = { key: ENVIRONMENT_PATHS_KEY };
|
||
|
||
const BulkChangeHostCVModal = ({
|
||
isOpen,
|
||
closeModal,
|
||
selectedCount,
|
||
orgId,
|
||
fetchBulkParams,
|
||
}) => {
|
||
const [selectedLifecycleEnv, setSelectedLifecycleEnv]
|
||
= useState([]);
|
||
|
||
const [selectedContentView, setSelectedContentView] = useState(null);
|
||
const [cvSelectOpen, setCVSelectOpen] = useState(false);
|
||
const dispatch = useDispatch();
|
||
const contentViewsInEnvResponse = useSelector(state => selectContentViews(state, '_FOR_DEFAULT_ENV'));
|
||
const { results } = contentViewsInEnvResponse;
|
||
const contentViewsInEnvStatus = useSelector(state => selectContentViewStatus(state, '_FOR_DEFAULT_ENV'));
|
||
const hostUpdateStatus = useSelector(state => selectAPIStatus(state, HOST_CV_AND_ENV_KEY));
|
||
const pathsUrl = `/organizations/${orgId}/environments/paths?permission_type=promotable`;
|
||
useAPI( // No TableWrapper here, so we can useAPI from Foreman
|
||
'get',
|
||
api.getApiUrl(pathsUrl),
|
||
ENV_PATH_OPTIONS,
|
||
);
|
||
const selectedContentViewId = results?.find(cv => cv.name === selectedContentView)?.id;
|
||
|
||
const handleModalClose = () => {
|
||
setCVSelectOpen(false);
|
||
setSelectedContentView(null);
|
||
setSelectedLifecycleEnv([]);
|
||
closeModal();
|
||
};
|
||
|
||
const selectedEnv = selectedLifecycleEnv?.[0];
|
||
const selectedEnvId = selectedEnv?.id;
|
||
|
||
const handleCVSelect = (event, selection) => {
|
||
setSelectedContentView(selection);
|
||
setCVSelectOpen(false);
|
||
};
|
||
|
||
const handleEnvSelect = (selection) => {
|
||
dispatch(getContentViews({
|
||
environment_id: selection[0].id,
|
||
include_default: true,
|
||
full_result: true,
|
||
order: 'default DESC', // show Default Organization View first
|
||
}, '_FOR_DEFAULT_ENV'));
|
||
setSelectedContentView(null);
|
||
setSelectedLifecycleEnv(selection);
|
||
};
|
||
const { results: contentViewsInEnv = [] } = contentViewsInEnvResponse;
|
||
const canSave = !!(selectedContentView && selectedLifecycleEnv.length);
|
||
|
||
const handleSave = () => {
|
||
const requestBody = {
|
||
content_view_id: selectedContentViewId,
|
||
environment_id: selectedEnvId,
|
||
organization_id: orgId,
|
||
included: {
|
||
search: fetchBulkParams(),
|
||
},
|
||
};
|
||
dispatch(bulkUpdateHostContentViewAndEnvironment(
|
||
requestBody, fetchBulkParams(),
|
||
handleModalClose, handleModalClose,
|
||
));
|
||
};
|
||
|
||
const cvPlaceholderText = getCVPlaceholderText({
|
||
environments: selectedLifecycleEnv,
|
||
cvSelectOptions: contentViewsInEnv,
|
||
contentViewsStatus: contentViewsInEnvStatus,
|
||
});
|
||
|
||
const stillLoading =
|
||
(contentViewsInEnvStatus === STATUS.PENDING || hostUpdateStatus === STATUS.PENDING);
|
||
const noContentViewsAvailable =
|
||
(contentViewsInEnv.length === 0 || selectedLifecycleEnv.length === 0);
|
||
|
||
const modalActions = ([
|
||
<Button
|
||
key="add"
|
||
ouiaId="bulk-change-host-cv-modal-add-button"
|
||
variant="primary"
|
||
onClick={handleSave}
|
||
isDisabled={!canSave || hostUpdateStatus === STATUS.PENDING}
|
||
isLoading={hostUpdateStatus === STATUS.PENDING}
|
||
>
|
||
{__('Save')}
|
||
</Button>,
|
||
<Button key="cancel" ouiaId="change-host-cv-modal-cancel-button" variant="link" onClick={handleModalClose}>
|
||
Cancel
|
||
</Button>,
|
||
]);
|
||
return (
|
||
<Modal
|
||
isOpen={isOpen}
|
||
onClose={handleModalClose}
|
||
onEscapePress={handleModalClose}
|
||
title={__('Edit content view environments')}
|
||
width="50%"
|
||
position="top"
|
||
actions={modalActions}
|
||
id="bulk-change-host-cv-modal"
|
||
key="bulk-change-host-cv-modal"
|
||
ouiaId="bulk-change-host-cv-modal"
|
||
>
|
||
<TextContent>
|
||
<Text
|
||
ouiaId="bulk-change-cv-options-description"
|
||
>
|
||
<FormattedMessage
|
||
defaultMessage={__('This will update the content view environments for {hosts}.')}
|
||
values={{
|
||
hosts: (
|
||
<strong>
|
||
<FormattedMessage
|
||
defaultMessage="{count, plural, one {# {singular}} other {# {plural}}}"
|
||
values={{
|
||
count: selectedCount,
|
||
singular: __('selected host'),
|
||
plural: __('selected hosts'),
|
||
}}
|
||
id="ccs-options-i18n"
|
||
/>
|
||
</strong>
|
||
),
|
||
}}
|
||
id="bulk-change-cv-options-description-i18n"
|
||
/>
|
||
</Text>
|
||
</TextContent>
|
||
{contentViewsInEnvStatus === STATUS.RESOLVED &&
|
||
!!selectedLifecycleEnv.length && contentViewsInEnv.length === 0 &&
|
||
<Alert
|
||
ouiaId="no-cv-alert"
|
||
variant="warning"
|
||
isInline
|
||
title={__('No content views available for the selected environment')}
|
||
style={{ marginBottom: '1rem' }}
|
||
>
|
||
<a href="/content_views">{__('View the Content Views page')}</a>
|
||
{__(' to manage and promote content views, or select a different environment.')}
|
||
</Alert>
|
||
}
|
||
<EnvironmentPaths
|
||
userCheckedItems={selectedLifecycleEnv}
|
||
setUserCheckedItems={handleEnvSelect}
|
||
publishing={false}
|
||
multiSelect={false}
|
||
headerText={__('Select environment')}
|
||
isDisabled={hostUpdateStatus === STATUS.PENDING}
|
||
/>
|
||
<ContentViewSelect
|
||
selections={selectedContentView}
|
||
onClear={() => setSelectedContentView(null)}
|
||
onSelect={handleCVSelect}
|
||
isOpen={cvSelectOpen}
|
||
isDisabled={stillLoading || noContentViewsAvailable}
|
||
onToggle={isExpanded => setCVSelectOpen(isExpanded)}
|
||
placeholderText={cvPlaceholderText}
|
||
>
|
||
{(contentViewsInEnv.length !== 0 && selectedLifecycleEnv.length !== 0) &&
|
||
contentViewsInEnv?.map(cv => (
|
||
<ContentViewSelectOption
|
||
key={cv.id}
|
||
value={cv.name}
|
||
cv={cv}
|
||
env={selectedLifecycleEnv[0]}
|
||
/>
|
||
))}
|
||
</ContentViewSelect>
|
||
<hr />
|
||
<TextContent>
|
||
<Text component={TextVariants.small} ouiaId="profile-upload-reminder-text">
|
||
{__('Errata and package information will be updated at the next host check-in or package action.')}
|
||
</Text>
|
||
</TextContent>
|
||
<hr />
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
BulkChangeHostCVModal.propTypes = {
|
||
isOpen: PropTypes.bool,
|
||
closeModal: PropTypes.func,
|
||
selectedCount: PropTypes.number.isRequired,
|
||
orgId: PropTypes.number.isRequired,
|
||
fetchBulkParams: PropTypes.func.isRequired,
|
||
};
|
||
|
||
BulkChangeHostCVModal.defaultProps = {
|
||
isOpen: false,
|
||
closeModal: () => {},
|
||
};
|
||
|
||
|
||
export default BulkChangeHostCVModal;
|
webpack/components/extensions/Hosts/BulkActions/BulkChangeHostCVModal/actions.js | ||
---|---|---|
import { translate as __ } from 'foremanReact/common/I18n';
|
||
import { API_OPERATIONS, put } from 'foremanReact/redux/API';
|
||
import { errorToast, renderTaskStartedToast } from '../../../../../scenes/Tasks/helpers';
|
||
import { foremanApi } from '../../../../../services/api';
|
||
import HOST_CV_AND_ENV_KEY from '../../../HostDetails/Cards/ContentViewDetailsCard/HostContentViewConstants';
|
||
|
||
export const bulkUpdateHostContentViewAndEnvironment =
|
||
(params, bulkParams, handleSuccess, handleError) => put({
|
||
type: API_OPERATIONS.PUT,
|
||
key: HOST_CV_AND_ENV_KEY,
|
||
url: foremanApi.getApiUrl('/hosts/bulk/environment_content_view'),
|
||
...bulkParams,
|
||
successToast: () => __('Host content view environments updating.'),
|
||
handleSuccess: (response) => {
|
||
if (handleSuccess) handleSuccess(response);
|
||
return renderTaskStartedToast(response.data);
|
||
},
|
||
handleError,
|
||
errorToast,
|
||
params,
|
||
});
|
||
|
||
export default bulkUpdateHostContentViewAndEnvironment;
|
webpack/components/extensions/Hosts/BulkActions/BulkChangeHostCVModal/index.js | ||
---|---|---|
import React, { useContext } from 'react';
|
||
import { useForemanOrganization } from 'foremanReact/Root/Context/ForemanContext';
|
||
import { ForemanActionsBarContext } from 'foremanReact/components/HostDetails/ActionsBar';
|
||
import { useForemanModal } from 'foremanReact/components/ForemanModal/ForemanModalHooks';
|
||
import BulkChangeHostCVModal from './BulkChangeHostCVModal';
|
||
|
||
const BulkChangeHostCVModalScene = () => {
|
||
const org = useForemanOrganization();
|
||
const { selectedCount, fetchBulkParams } = useContext(ForemanActionsBarContext);
|
||
const { modalOpen, setModalClosed } = useForemanModal({ id: 'bulk-change-cv-modal' });
|
||
|
||
return (
|
||
<BulkChangeHostCVModal
|
||
key="bulk-change-cv-modal"
|
||
selectedCount={selectedCount}
|
||
fetchBulkParams={fetchBulkParams}
|
||
isOpen={modalOpen}
|
||
closeModal={setModalClosed}
|
||
orgId={org?.id}
|
||
/>
|
||
|
||
);
|
||
};
|
||
|
||
export default BulkChangeHostCVModalScene;
|
webpack/components/extensions/Hosts/BulkActions/__tests__/bulkChangeHostCVModal.test.js | ||
---|---|---|
import React from 'react';
|
||
import { renderWithRedux, patientlyWaitFor, act } from 'react-testing-lib-wrapper';
|
||
import userEvent from '@testing-library/user-event';
|
||
import BulkChangeHostCVModal from '../BulkChangeHostCVModal/BulkChangeHostCVModal.js';
|
||
import mockEnvPaths from '../../../HostDetails/Cards/ContentViewDetailsCard/__tests__/envPaths.fixtures.json';
|
||
import mockContentViews from '../../../HostDetails/Cards/ContentViewDetailsCard/__tests__/contentViews.fixtures.json';
|
||
import HOST_CV_AND_ENV_KEY from '../../../HostDetails/Cards/ContentViewDetailsCard/HostContentViewConstants';
|
||
import { assertNockRequest, nockInstance } from '../../../../../test-utils/nockWrapper';
|
||
import katelloApi from '../../../../../services/api';
|
||
|
||
const contentViews = katelloApi.getApiUrl('/content_views');
|
||
const renderOptions = () => ({
|
||
apiNamespace: HOST_CV_AND_ENV_KEY,
|
||
initialState: {
|
||
API: {
|
||
HOST_DETAILS: {
|
||
response: {
|
||
id: 1,
|
||
name: 'test-host',
|
||
content_facet_attributes: {
|
||
content_view_id: 1,
|
||
lifecycle_environment_id: 1,
|
||
},
|
||
organization_id: 1,
|
||
},
|
||
status: 'RESOLVED',
|
||
},
|
||
ENVIRONMENT_PATHS: {
|
||
response: mockEnvPaths,
|
||
status: 'RESOLVED',
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
let firstEnvPath;
|
||
let firstCV;
|
||
let secondCV;
|
||
let firstEnv;
|
||
|
||
const cvQuery = {
|
||
organization_id: 1,
|
||
include_permissions: true,
|
||
include_default: true,
|
||
environment_id: 1,
|
||
full_result: true,
|
||
order: 'default DESC',
|
||
};
|
||
|
||
beforeEach(() => {
|
||
const { results } = mockEnvPaths;
|
||
[firstEnvPath] = results;
|
||
const { environments: envResults } = firstEnvPath;
|
||
[firstEnv] = envResults;
|
||
const { results: cvResults } = mockContentViews;
|
||
[firstCV, secondCV] = cvResults;
|
||
});
|
||
|
||
jest.mock('foremanReact/common/hooks/API/APIHooks', () => ({
|
||
useAPI: jest.fn(),
|
||
}));
|
||
|
||
test('Displays environment paths', async (done) => {
|
||
const jsx = (
|
||
<BulkChangeHostCVModal
|
||
isOpen
|
||
closeModal={jest.fn()}
|
||
selectedCount={1}
|
||
fetchBulkParams={() => 'id ^ 1'}
|
||
orgId={1}
|
||
/>
|
||
);
|
||
const { getAllByText }
|
||
= renderWithRedux(jsx, renderOptions());
|
||
|
||
await patientlyWaitFor(() =>
|
||
expect(getAllByText(firstEnv.name)[0]).toBeInTheDocument());
|
||
done();
|
||
});
|
||
|
||
test('Select an env > call CV API > select a CV > Save button is enabled', async (done) => {
|
||
const contentViewsScope = nockInstance
|
||
.get(contentViews)
|
||
.query(cvQuery)
|
||
.reply(200, mockContentViews);
|
||
|
||
const jsx = (
|
||
<BulkChangeHostCVModal
|
||
isOpen
|
||
closeModal={jest.fn()}
|
||
selectedCount={1}
|
||
fetchBulkParams={() => 'id ^ 1'}
|
||
orgId={1}
|
||
/>
|
||
);
|
||
const {
|
||
getAllByText, getByText,
|
||
findByPlaceholderText, getAllByRole,
|
||
} = renderWithRedux(jsx, renderOptions());
|
||
|
||
await patientlyWaitFor(() => {
|
||
const envLabel = getAllByText(firstEnv.name)[0];
|
||
expect(envLabel).toBeInTheDocument();
|
||
});
|
||
|
||
const envRadio = getAllByRole('radio', { name: firstEnv.name })[0];
|
||
expect(envRadio).toBeInTheDocument();
|
||
|
||
await act(async () => {
|
||
userEvent.click(envRadio); // Select the Library environment
|
||
|
||
const cvDropdown = await findByPlaceholderText('Select a content view');
|
||
expect(cvDropdown).toBeInTheDocument();
|
||
|
||
userEvent.click(cvDropdown); // Open the CV dropdown
|
||
|
||
|
||
[firstCV, secondCV].forEach((cv) => {
|
||
expect(getByText(cv.name)).toBeInTheDocument(); // the content view names should be showing
|
||
});
|
||
|
||
|
||
userEvent.click(getByText(secondCV.name)); // Select the second content view
|
||
});
|
||
|
||
// find the Save button and assert that it is enabled
|
||
const saveButton = getAllByRole('button', { name: 'Save' })[0];
|
||
expect(saveButton).toBeInTheDocument();
|
||
expect(saveButton).toHaveAttribute('aria-disabled', 'false');
|
||
|
||
assertNockRequest(contentViewsScope, done);
|
||
act(done);
|
||
});
|
webpack/global_index.js | ||
---|---|---|
import HostsIndexActionsBar from './components/extensions/Hosts/ActionsBar';
|
||
import RecentCommunicationCardExtensions from './components/extensions/HostDetails/DetailsTabCards/RecentCommunicationCardExtensions';
|
||
import SystemPurposeCard from './components/extensions/HostDetails/Cards/SystemPurposeCard/SystemPurposeCard';
|
||
import BulkChangeHostCVModal from './components/extensions/Hosts/BulkActions/BulkChangeHostCVModal/index.js';
|
||
|
||
|
||
import ActivationKeysSearch from './components/ActivationKeysSearch';
|
||
... | ... | |
|
||
addGlobalFill('host-tab-details-cards', 'HW properties', <HwPropertiesCard key="hw-properties" />, 200);
|
||
|
||
// Hosts Index page extensions
|
||
addGlobalFill('_all-hosts-modals', 'BulkChangeHostCVModal', <BulkChangeHostCVModal key="bulk-change-host-cv-modal" />, 100);
|
||
|
||
registerColumns(hostsIndexColumnExtensions);
|
||
|
||
|
Also available in: Unified diff
Fixes #37336 - Add bulk change content view modal (#10959)