mirror of
https://github.com/ansible/awx.git
synced 2026-02-24 06:26:00 -03:30
Merge pull request #8041 from mabashian/7680-inv-pending-delete
Adds support for pending deletion on inventory list Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
@@ -27,6 +27,7 @@ const initialValues = {
|
|||||||
changes: false,
|
changes: false,
|
||||||
escalation: false,
|
escalation: false,
|
||||||
extra_vars: '---',
|
extra_vars: '---',
|
||||||
|
module_name: 'shell',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('<AdHocDetailsStep />', () => {
|
describe('<AdHocDetailsStep />', () => {
|
||||||
|
|||||||
@@ -9,17 +9,24 @@ import useRequest, { useDismissableError } from '../../util/useRequest';
|
|||||||
import AlertModal from '../AlertModal';
|
import AlertModal from '../AlertModal';
|
||||||
import ErrorDetail from '../ErrorDetail';
|
import ErrorDetail from '../ErrorDetail';
|
||||||
|
|
||||||
function CopyButton({ i18n, copyItem, onLoading, onDoneLoading, helperText }) {
|
function CopyButton({
|
||||||
|
i18n,
|
||||||
|
copyItem,
|
||||||
|
isDisabled,
|
||||||
|
onCopyStart,
|
||||||
|
onCopyFinish,
|
||||||
|
helperText,
|
||||||
|
}) {
|
||||||
const { isLoading, error: copyError, request: copyItemToAPI } = useRequest(
|
const { isLoading, error: copyError, request: copyItemToAPI } = useRequest(
|
||||||
copyItem
|
copyItem
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return onLoading();
|
return onCopyStart();
|
||||||
}
|
}
|
||||||
return onDoneLoading();
|
return onCopyFinish();
|
||||||
}, [isLoading, onLoading, onDoneLoading]);
|
}, [isLoading, onCopyStart, onCopyFinish]);
|
||||||
|
|
||||||
const { error, dismissError } = useDismissableError(copyError);
|
const { error, dismissError } = useDismissableError(copyError);
|
||||||
|
|
||||||
@@ -27,6 +34,7 @@ function CopyButton({ i18n, copyItem, onLoading, onDoneLoading, helperText }) {
|
|||||||
<>
|
<>
|
||||||
<Tooltip content={helperText.tooltip} position="top">
|
<Tooltip content={helperText.tooltip} position="top">
|
||||||
<Button
|
<Button
|
||||||
|
isDisabled={isDisabled}
|
||||||
aria-label={i18n._(t`Copy`)}
|
aria-label={i18n._(t`Copy`)}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
onClick={copyItemToAPI}
|
onClick={copyItemToAPI}
|
||||||
@@ -50,11 +58,17 @@ function CopyButton({ i18n, copyItem, onLoading, onDoneLoading, helperText }) {
|
|||||||
|
|
||||||
CopyButton.propTypes = {
|
CopyButton.propTypes = {
|
||||||
copyItem: PropTypes.func.isRequired,
|
copyItem: PropTypes.func.isRequired,
|
||||||
onLoading: PropTypes.func.isRequired,
|
onCopyStart: PropTypes.func.isRequired,
|
||||||
onDoneLoading: PropTypes.func.isRequired,
|
onCopyFinish: PropTypes.func.isRequired,
|
||||||
helperText: PropTypes.shape({
|
helperText: PropTypes.shape({
|
||||||
tooltip: PropTypes.string.isRequired,
|
tooltip: PropTypes.string.isRequired,
|
||||||
errorMessage: PropTypes.string.isRequired,
|
errorMessage: PropTypes.string.isRequired,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
|
isDisabled: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
CopyButton.defaultProps = {
|
||||||
|
isDisabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
export default withI18n()(CopyButton);
|
export default withI18n()(CopyButton);
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ describe('<CopyButton/>', () => {
|
|||||||
test('shold mount properly', () => {
|
test('shold mount properly', () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<CopyButton
|
<CopyButton
|
||||||
onLoading={() => {}}
|
onCopyStart={() => {}}
|
||||||
onDoneLoading={() => {}}
|
onCopyFinish={() => {}}
|
||||||
copyItem={() => {}}
|
copyItem={() => {}}
|
||||||
helperText={{
|
helperText={{
|
||||||
tooltip: `Copy Template`,
|
tooltip: `Copy Template`,
|
||||||
@@ -22,8 +22,8 @@ describe('<CopyButton/>', () => {
|
|||||||
test('should render proper tooltip', () => {
|
test('should render proper tooltip', () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<CopyButton
|
<CopyButton
|
||||||
onLoading={() => {}}
|
onCopyStart={() => {}}
|
||||||
onDoneLoading={() => {}}
|
onCopyFinish={() => {}}
|
||||||
copyItem={() => {}}
|
copyItem={() => {}}
|
||||||
helperText={{
|
helperText={{
|
||||||
tooltip: `Copy Template`,
|
tooltip: `Copy Template`,
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ class ErrorDetail extends Component {
|
|||||||
<CardBody>
|
<CardBody>
|
||||||
{Array.isArray(message) ? (
|
{Array.isArray(message) ? (
|
||||||
<ul>
|
<ul>
|
||||||
{message.map(m => (
|
{message.map(m =>
|
||||||
<li key={m}>{m}</li>
|
typeof m === 'string' ? <li key={m}>{m}</li> : null
|
||||||
))}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
message
|
message
|
||||||
|
|||||||
@@ -2,18 +2,24 @@ import React, { useContext, useEffect, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
func,
|
func,
|
||||||
bool,
|
bool,
|
||||||
|
node,
|
||||||
number,
|
number,
|
||||||
string,
|
string,
|
||||||
arrayOf,
|
arrayOf,
|
||||||
shape,
|
shape,
|
||||||
checkPropTypes,
|
checkPropTypes,
|
||||||
} from 'prop-types';
|
} from 'prop-types';
|
||||||
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
|
import styled from 'styled-components';
|
||||||
|
import { Alert, Button, DropdownItem, Tooltip } from '@patternfly/react-core';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import AlertModal from '../AlertModal';
|
import AlertModal from '../AlertModal';
|
||||||
import { KebabifiedContext } from '../../contexts/Kebabified';
|
import { KebabifiedContext } from '../../contexts/Kebabified';
|
||||||
|
|
||||||
|
const WarningMessage = styled(Alert)`
|
||||||
|
margin-top: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
const requireNameOrUsername = props => {
|
const requireNameOrUsername = props => {
|
||||||
const { name, username } = props;
|
const { name, username } = props;
|
||||||
if (!name && !username) {
|
if (!name && !username) {
|
||||||
@@ -64,6 +70,7 @@ function ToolbarDeleteButton({
|
|||||||
pluralizedItemName,
|
pluralizedItemName,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
warningMessage,
|
||||||
i18n,
|
i18n,
|
||||||
}) {
|
}) {
|
||||||
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
|
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
|
||||||
@@ -171,6 +178,9 @@ function ToolbarDeleteButton({
|
|||||||
<br />
|
<br />
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
{warningMessage && (
|
||||||
|
<WarningMessage variant="warning" isInline title={warningMessage} />
|
||||||
|
)}
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -182,11 +192,13 @@ ToolbarDeleteButton.propTypes = {
|
|||||||
itemsToDelete: arrayOf(ItemToDelete).isRequired,
|
itemsToDelete: arrayOf(ItemToDelete).isRequired,
|
||||||
pluralizedItemName: string,
|
pluralizedItemName: string,
|
||||||
errorMessage: string,
|
errorMessage: string,
|
||||||
|
warningMessage: node,
|
||||||
};
|
};
|
||||||
|
|
||||||
ToolbarDeleteButton.defaultProps = {
|
ToolbarDeleteButton.defaultProps = {
|
||||||
pluralizedItemName: 'Items',
|
pluralizedItemName: 'Items',
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
|
warningMessage: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withI18n()(ToolbarDeleteButton);
|
export default withI18n()(ToolbarDeleteButton);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ exports[`<ToolbarDeleteButton /> should render button 1`] = `
|
|||||||
itemsToDelete={Array []}
|
itemsToDelete={Array []}
|
||||||
onDelete={[Function]}
|
onDelete={[Function]}
|
||||||
pluralizedItemName="Items"
|
pluralizedItemName="Items"
|
||||||
|
warningMessage={null}
|
||||||
>
|
>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content="Select a row to delete"
|
content="Select a row to delete"
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ function CredentialListItem({
|
|||||||
await fetchCredentials();
|
await fetchCredentials();
|
||||||
}, [credential.id, credential.name, fetchCredentials]);
|
}, [credential.id, credential.name, fetchCredentials]);
|
||||||
|
|
||||||
|
const handleCopyStart = useCallback(() => {
|
||||||
|
setIsDisabled(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopyFinish = useCallback(() => {
|
||||||
|
setIsDisabled(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataListItem
|
<DataListItem
|
||||||
key={credential.id}
|
key={credential.id}
|
||||||
@@ -95,8 +103,8 @@ function CredentialListItem({
|
|||||||
{credential.summary_fields.user_capabilities.copy && (
|
{credential.summary_fields.user_capabilities.copy && (
|
||||||
<CopyButton
|
<CopyButton
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
onLoading={() => setIsDisabled(true)}
|
onCopyStart={handleCopyStart}
|
||||||
onDoneLoading={() => setIsDisabled(false)}
|
onCopyFinish={handleCopyFinish}
|
||||||
copyItem={copyCredential}
|
copyItem={copyCredential}
|
||||||
helperText={{
|
helperText={{
|
||||||
tooltip: i18n._(t`Copy Credential`),
|
tooltip: i18n._(t`Copy Credential`),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link, useHistory } from 'react-router-dom';
|
|||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Button, Chip } from '@patternfly/react-core';
|
import { Button, Chip } from '@patternfly/react-core';
|
||||||
|
import AlertModal from '../../../components/AlertModal';
|
||||||
import { CardBody, CardActionsRow } from '../../../components/Card';
|
import { CardBody, CardActionsRow } from '../../../components/Card';
|
||||||
import {
|
import {
|
||||||
DetailList,
|
DetailList,
|
||||||
@@ -11,11 +12,12 @@ import {
|
|||||||
} from '../../../components/DetailList';
|
} from '../../../components/DetailList';
|
||||||
import { VariablesDetail } from '../../../components/CodeMirrorInput';
|
import { VariablesDetail } from '../../../components/CodeMirrorInput';
|
||||||
import DeleteButton from '../../../components/DeleteButton';
|
import DeleteButton from '../../../components/DeleteButton';
|
||||||
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
import ContentError from '../../../components/ContentError';
|
import ContentError from '../../../components/ContentError';
|
||||||
import ContentLoading from '../../../components/ContentLoading';
|
import ContentLoading from '../../../components/ContentLoading';
|
||||||
import ChipGroup from '../../../components/ChipGroup';
|
import ChipGroup from '../../../components/ChipGroup';
|
||||||
import { InventoriesAPI } from '../../../api';
|
import { InventoriesAPI } from '../../../api';
|
||||||
import useRequest from '../../../util/useRequest';
|
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||||
import { Inventory } from '../../../types';
|
import { Inventory } from '../../../types';
|
||||||
|
|
||||||
function InventoryDetail({ inventory, i18n }) {
|
function InventoryDetail({ inventory, i18n }) {
|
||||||
@@ -24,7 +26,7 @@ function InventoryDetail({ inventory, i18n }) {
|
|||||||
const {
|
const {
|
||||||
result: instanceGroups,
|
result: instanceGroups,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error: instanceGroupsError,
|
||||||
request: fetchInstanceGroups,
|
request: fetchInstanceGroups,
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
@@ -38,10 +40,14 @@ function InventoryDetail({ inventory, i18n }) {
|
|||||||
fetchInstanceGroups();
|
fetchInstanceGroups();
|
||||||
}, [fetchInstanceGroups]);
|
}, [fetchInstanceGroups]);
|
||||||
|
|
||||||
const deleteInventory = async () => {
|
const { request: deleteInventory, error: deleteError } = useRequest(
|
||||||
await InventoriesAPI.destroy(inventory.id);
|
useCallback(async () => {
|
||||||
history.push(`/inventories`);
|
await InventoriesAPI.destroy(inventory.id);
|
||||||
};
|
history.push(`/inventories`);
|
||||||
|
}, [inventory.id, history])
|
||||||
|
);
|
||||||
|
|
||||||
|
const { error, dismissError } = useDismissableError(deleteError);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
organization,
|
organization,
|
||||||
@@ -52,8 +58,8 @@ function InventoryDetail({ inventory, i18n }) {
|
|||||||
return <ContentLoading />;
|
return <ContentLoading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (instanceGroupsError) {
|
||||||
return <ContentError error={error} />;
|
return <ContentError error={instanceGroupsError} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -123,6 +129,18 @@ function InventoryDetail({ inventory, i18n }) {
|
|||||||
</DeleteButton>
|
</DeleteButton>
|
||||||
)}
|
)}
|
||||||
</CardActionsRow>
|
</CardActionsRow>
|
||||||
|
{/* Update delete modal to show dependencies https://github.com/ansible/awx/issues/5546 */}
|
||||||
|
{error && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={error}
|
||||||
|
variant="error"
|
||||||
|
title={i18n._(t`Error!`)}
|
||||||
|
onClose={dismissError}
|
||||||
|
>
|
||||||
|
{i18n._(t`Failed to delete inventory.`)}
|
||||||
|
<ErrorDetail error={error} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useLocation, useRouteMatch } from 'react-router-dom';
|
|||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Card, PageSection } from '@patternfly/react-core';
|
import { Card, PageSection } from '@patternfly/react-core';
|
||||||
|
|
||||||
import { InventoriesAPI } from '../../../api';
|
import { InventoriesAPI } from '../../../api';
|
||||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||||
import AlertModal from '../../../components/AlertModal';
|
import AlertModal from '../../../components/AlertModal';
|
||||||
@@ -73,20 +72,26 @@ function InventoryList({ i18n }) {
|
|||||||
|
|
||||||
const fetchInventoriesById = useCallback(
|
const fetchInventoriesById = useCallback(
|
||||||
async ids => {
|
async ids => {
|
||||||
const params = parseQueryString(QS_CONFIG, location.search);
|
const params = { ...parseQueryString(QS_CONFIG, location.search) };
|
||||||
params.id__in = ids.join(',');
|
params.id__in = ids.join(',');
|
||||||
const { data } = await InventoriesAPI.read(params);
|
const { data } = await InventoriesAPI.read(params);
|
||||||
return data.results;
|
return data.results;
|
||||||
},
|
},
|
||||||
[location.search] // eslint-disable-line react-hooks/exhaustive-deps
|
[location.search] // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
);
|
);
|
||||||
const inventories = useWsInventories(results, fetchInventoriesById);
|
|
||||||
|
const inventories = useWsInventories(
|
||||||
|
results,
|
||||||
|
fetchInventories,
|
||||||
|
fetchInventoriesById,
|
||||||
|
QS_CONFIG
|
||||||
|
);
|
||||||
|
|
||||||
const isAllSelected =
|
const isAllSelected =
|
||||||
selected.length === inventories.length && selected.length > 0;
|
selected.length === inventories.length && selected.length > 0;
|
||||||
const {
|
const {
|
||||||
isLoading: isDeleteLoading,
|
isLoading: isDeleteLoading,
|
||||||
deleteItems: deleteTeams,
|
deleteItems: deleteInventories,
|
||||||
deletionError,
|
deletionError,
|
||||||
clearDeletionError,
|
clearDeletionError,
|
||||||
} = useDeleteItems(
|
} = useDeleteItems(
|
||||||
@@ -94,14 +99,12 @@ function InventoryList({ i18n }) {
|
|||||||
return Promise.all(selected.map(team => InventoriesAPI.destroy(team.id)));
|
return Promise.all(selected.map(team => InventoriesAPI.destroy(team.id)));
|
||||||
}, [selected]),
|
}, [selected]),
|
||||||
{
|
{
|
||||||
qsConfig: QS_CONFIG,
|
|
||||||
allItemsSelected: isAllSelected,
|
allItemsSelected: isAllSelected,
|
||||||
fetchItems: fetchInventories,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleInventoryDelete = async () => {
|
const handleInventoryDelete = async () => {
|
||||||
await deleteTeams();
|
await deleteInventories();
|
||||||
setSelected([]);
|
setSelected([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,10 +116,12 @@ function InventoryList({ i18n }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = row => {
|
const handleSelect = row => {
|
||||||
if (selected.some(s => s.id === row.id)) {
|
if (!row.pending_deletion) {
|
||||||
setSelected(selected.filter(s => s.id !== row.id));
|
if (selected.some(s => s.id === row.id)) {
|
||||||
} else {
|
setSelected(selected.filter(s => s.id !== row.id));
|
||||||
setSelected(selected.concat(row));
|
} else {
|
||||||
|
setSelected(selected.concat(row));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -187,6 +192,10 @@ function InventoryList({ i18n }) {
|
|||||||
onDelete={handleInventoryDelete}
|
onDelete={handleInventoryDelete}
|
||||||
itemsToDelete={selected}
|
itemsToDelete={selected}
|
||||||
pluralizedItemName={i18n._(t`Inventories`)}
|
pluralizedItemName={i18n._(t`Inventories`)}
|
||||||
|
warningMessage={i18n._(
|
||||||
|
'{numItemsToDelete, plural, one {The inventory will be in a pending status until the final delete is processed.} other {The inventories will be in a pending status until the final delete is processed.}}',
|
||||||
|
{ numItemsToDelete: selected.length }
|
||||||
|
)}
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
DataListItem,
|
DataListItem,
|
||||||
DataListItemCells,
|
DataListItemCells,
|
||||||
DataListItemRow,
|
DataListItemRow,
|
||||||
|
Label,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||||
@@ -51,6 +52,14 @@ function InventoryListItem({
|
|||||||
await fetchInventories();
|
await fetchInventories();
|
||||||
}, [inventory.id, inventory.name, fetchInventories]);
|
}, [inventory.id, inventory.name, fetchInventories]);
|
||||||
|
|
||||||
|
const handleCopyStart = useCallback(() => {
|
||||||
|
setIsDisabled(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopyFinish = useCallback(() => {
|
||||||
|
setIsDisabled(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const labelId = `check-action-${inventory.id}`;
|
const labelId = `check-action-${inventory.id}`;
|
||||||
|
|
||||||
let syncStatus = 'disabled';
|
let syncStatus = 'disabled';
|
||||||
@@ -69,6 +78,7 @@ function InventoryListItem({
|
|||||||
<DataListItemRow>
|
<DataListItemRow>
|
||||||
<DataListCheck
|
<DataListCheck
|
||||||
id={`select-inventory-${inventory.id}`}
|
id={`select-inventory-${inventory.id}`}
|
||||||
|
isDisabled={inventory.pending_deletion}
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={onSelect}
|
onChange={onSelect}
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
@@ -79,52 +89,63 @@ function InventoryListItem({
|
|||||||
<SyncStatusIndicator status={syncStatus} />
|
<SyncStatusIndicator status={syncStatus} />
|
||||||
</DataListCell>,
|
</DataListCell>,
|
||||||
<DataListCell key="name">
|
<DataListCell key="name">
|
||||||
<Link to={`${detailUrl}`}>
|
{inventory.pending_deletion ? (
|
||||||
<b>{inventory.name}</b>
|
<b>{inventory.name}</b>
|
||||||
</Link>
|
) : (
|
||||||
|
<Link to={`${detailUrl}`}>
|
||||||
|
<b>{inventory.name}</b>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</DataListCell>,
|
</DataListCell>,
|
||||||
<DataListCell key="kind">
|
<DataListCell key="kind">
|
||||||
{inventory.kind === 'smart'
|
{inventory.kind === 'smart'
|
||||||
? i18n._(t`Smart Inventory`)
|
? i18n._(t`Smart Inventory`)
|
||||||
: i18n._(t`Inventory`)}
|
: i18n._(t`Inventory`)}
|
||||||
</DataListCell>,
|
</DataListCell>,
|
||||||
|
inventory.pending_deletion && (
|
||||||
|
<DataListCell alignRight isFilled={false} key="pending-delete">
|
||||||
|
<Label color="red">{i18n._(t`Pending delete`)}</Label>
|
||||||
|
</DataListCell>
|
||||||
|
),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DataListAction
|
{!inventory.pending_deletion && (
|
||||||
aria-label="actions"
|
<DataListAction
|
||||||
aria-labelledby={labelId}
|
aria-label="actions"
|
||||||
id={labelId}
|
aria-labelledby={labelId}
|
||||||
>
|
id={labelId}
|
||||||
{inventory.summary_fields.user_capabilities.edit ? (
|
>
|
||||||
<Tooltip content={i18n._(t`Edit Inventory`)} position="top">
|
{inventory.summary_fields.user_capabilities.edit ? (
|
||||||
<Button
|
<Tooltip content={i18n._(t`Edit Inventory`)} position="top">
|
||||||
|
<Button
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
aria-label={i18n._(t`Edit Inventory`)}
|
||||||
|
variant="plain"
|
||||||
|
component={Link}
|
||||||
|
to={`/inventories/${
|
||||||
|
inventory.kind === 'smart' ? 'smart_inventory' : 'inventory'
|
||||||
|
}/${inventory.id}/edit`}
|
||||||
|
>
|
||||||
|
<PencilAltIcon />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
{inventory.summary_fields.user_capabilities.copy && (
|
||||||
|
<CopyButton
|
||||||
|
copyItem={copyInventory}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
aria-label={i18n._(t`Edit Inventory`)}
|
onCopyStart={handleCopyStart}
|
||||||
variant="plain"
|
onCopyFinish={handleCopyFinish}
|
||||||
component={Link}
|
helperText={{
|
||||||
to={`/inventories/${
|
tooltip: i18n._(t`Copy Inventory`),
|
||||||
inventory.kind === 'smart' ? 'smart_inventory' : 'inventory'
|
errorMessage: i18n._(t`Failed to copy inventory.`),
|
||||||
}/${inventory.id}/edit`}
|
}}
|
||||||
>
|
/>
|
||||||
<PencilAltIcon />
|
)}
|
||||||
</Button>
|
</DataListAction>
|
||||||
</Tooltip>
|
)}
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)}
|
|
||||||
{inventory.summary_fields.user_capabilities.copy && (
|
|
||||||
<CopyButton
|
|
||||||
copyItem={copyInventory}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
onLoading={() => setIsDisabled(true)}
|
|
||||||
onDoneLoading={() => setIsDisabled(false)}
|
|
||||||
helperText={{
|
|
||||||
tooltip: i18n._(t`Copy Inventory`),
|
|
||||||
errorMessage: i18n._(t`Failed to copy inventory.`),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DataListAction>
|
|
||||||
</DataListItemRow>
|
</DataListItemRow>
|
||||||
</DataListItem>
|
</DataListItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useLocation, useHistory } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
parseQueryString,
|
||||||
|
replaceParams,
|
||||||
|
encodeNonDefaultQueryString,
|
||||||
|
} from '../../../util/qs';
|
||||||
import useWebsocket from '../../../util/useWebsocket';
|
import useWebsocket from '../../../util/useWebsocket';
|
||||||
import useThrottle from '../../../util/useThrottle';
|
import useThrottle from '../../../util/useThrottle';
|
||||||
|
|
||||||
export default function useWsInventories(
|
export default function useWsInventories(
|
||||||
initialInventories,
|
initialInventories,
|
||||||
fetchInventoriesById
|
fetchInventories,
|
||||||
|
fetchInventoriesById,
|
||||||
|
qsConfig
|
||||||
) {
|
) {
|
||||||
|
const location = useLocation();
|
||||||
|
const history = useHistory();
|
||||||
const [inventories, setInventories] = useState(initialInventories);
|
const [inventories, setInventories] = useState(initialInventories);
|
||||||
const [inventoriesToFetch, setInventoriesToFetch] = useState([]);
|
const [inventoriesToFetch, setInventoriesToFetch] = useState([]);
|
||||||
const throttledInventoriesToFetch = useThrottle(inventoriesToFetch, 5000);
|
const throttledInventoriesToFetch = useThrottle(inventoriesToFetch, 5000);
|
||||||
@@ -53,7 +63,8 @@ export default function useWsInventories(
|
|||||||
function processWsMessage() {
|
function processWsMessage() {
|
||||||
if (
|
if (
|
||||||
!lastMessage?.inventory_id ||
|
!lastMessage?.inventory_id ||
|
||||||
lastMessage.type !== 'inventory_update'
|
(lastMessage.type !== 'inventory_update' &&
|
||||||
|
lastMessage.group_name !== 'inventories')
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -64,16 +75,59 @@ export default function useWsInventories(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['pending', 'waiting', 'running'].includes(lastMessage.status)) {
|
const params = parseQueryString(qsConfig, location.search);
|
||||||
enqueueId(lastMessage.inventory_id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inventory = inventories[index];
|
const inventory = inventories[index];
|
||||||
const updatedInventory = {
|
const updatedInventory = {
|
||||||
...inventory,
|
...inventory,
|
||||||
isSourceSyncRunning: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastMessage.group_name === 'inventories' &&
|
||||||
|
lastMessage.status === 'deleted' &&
|
||||||
|
inventories.length === 1 &&
|
||||||
|
params.page > 1
|
||||||
|
) {
|
||||||
|
// We've deleted the last inventory on this page so we'll
|
||||||
|
// try to navigate back to the previous page
|
||||||
|
const newParams = encodeNonDefaultQueryString(
|
||||||
|
qsConfig,
|
||||||
|
replaceParams(params, {
|
||||||
|
page: params.page - 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
history.push(`${location.pathname}?${newParams}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastMessage.group_name === 'inventories' &&
|
||||||
|
lastMessage.status === 'deleted'
|
||||||
|
) {
|
||||||
|
fetchInventories();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!['pending', 'waiting', 'running', 'pending_deletion'].includes(
|
||||||
|
lastMessage.status
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
enqueueId(lastMessage.inventory_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastMessage.group_name === 'inventories' &&
|
||||||
|
lastMessage.status === 'pending_deletion'
|
||||||
|
) {
|
||||||
|
updatedInventory.pending_deletion = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastMessage.group_name !== 'inventories') {
|
||||||
|
updatedInventory.isSourceSyncRunning = true;
|
||||||
|
}
|
||||||
|
|
||||||
setInventories([
|
setInventories([
|
||||||
...inventories.slice(0, index),
|
...inventories.slice(0, index),
|
||||||
updatedInventory,
|
updatedInventory,
|
||||||
|
|||||||
@@ -16,11 +16,25 @@ jest.mock('../../../util/useThrottle', () => ({
|
|||||||
function TestInner() {
|
function TestInner() {
|
||||||
return <div />;
|
return <div />;
|
||||||
}
|
}
|
||||||
function Test({ inventories, fetch }) {
|
function Test({
|
||||||
const syncedJobs = useWsInventories(inventories, fetch);
|
inventories,
|
||||||
return <TestInner inventories={syncedJobs} />;
|
fetchInventories,
|
||||||
|
fetchInventoriesById,
|
||||||
|
qsConfig,
|
||||||
|
}) {
|
||||||
|
const syncedInventories = useWsInventories(
|
||||||
|
inventories,
|
||||||
|
fetchInventories,
|
||||||
|
fetchInventoriesById,
|
||||||
|
qsConfig
|
||||||
|
);
|
||||||
|
return <TestInner inventories={syncedInventories} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const QS_CONFIG = {
|
||||||
|
defaultParams: {},
|
||||||
|
};
|
||||||
|
|
||||||
describe('useWsInventories hook', () => {
|
describe('useWsInventories hook', () => {
|
||||||
let debug;
|
let debug;
|
||||||
let wrapper;
|
let wrapper;
|
||||||
@@ -31,14 +45,16 @@ describe('useWsInventories hook', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
global.console.debug = debug;
|
global.console.debug = debug;
|
||||||
|
WS.clean();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return inventories list', () => {
|
test('should return inventories list', () => {
|
||||||
const inventories = [{ id: 1 }];
|
const inventories = [{ id: 1 }];
|
||||||
wrapper = mountWithContexts(<Test inventories={inventories} />);
|
wrapper = mountWithContexts(
|
||||||
|
<Test inventories={inventories} qsConfig={QS_CONFIG} />
|
||||||
|
);
|
||||||
|
|
||||||
expect(wrapper.find('TestInner').prop('inventories')).toEqual(inventories);
|
expect(wrapper.find('TestInner').prop('inventories')).toEqual(inventories);
|
||||||
WS.clean();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should establish websocket connection', async () => {
|
test('should establish websocket connection', async () => {
|
||||||
@@ -47,7 +63,9 @@ describe('useWsInventories hook', () => {
|
|||||||
|
|
||||||
const inventories = [{ id: 1 }];
|
const inventories = [{ id: 1 }];
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = await mountWithContexts(<Test inventories={inventories} />);
|
wrapper = await mountWithContexts(
|
||||||
|
<Test inventories={inventories} qsConfig={QS_CONFIG} />
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
await mockServer.connected;
|
await mockServer.connected;
|
||||||
@@ -61,7 +79,6 @@ describe('useWsInventories hook', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
WS.clean();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should update inventory sync status', async () => {
|
test('should update inventory sync status', async () => {
|
||||||
@@ -70,7 +87,9 @@ describe('useWsInventories hook', () => {
|
|||||||
|
|
||||||
const inventories = [{ id: 1 }];
|
const inventories = [{ id: 1 }];
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = await mountWithContexts(<Test inventories={inventories} />);
|
wrapper = await mountWithContexts(
|
||||||
|
<Test inventories={inventories} qsConfig={QS_CONFIG} />
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
await mockServer.connected;
|
await mockServer.connected;
|
||||||
@@ -98,17 +117,22 @@ describe('useWsInventories hook', () => {
|
|||||||
expect(
|
expect(
|
||||||
wrapper.find('TestInner').prop('inventories')[0].isSourceSyncRunning
|
wrapper.find('TestInner').prop('inventories')[0].isSourceSyncRunning
|
||||||
).toEqual(true);
|
).toEqual(true);
|
||||||
WS.clean();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should fetch fresh inventory after sync runs', async () => {
|
test('should fetch fresh inventory after sync runs', async () => {
|
||||||
global.document.cookie = 'csrftoken=abc123';
|
global.document.cookie = 'csrftoken=abc123';
|
||||||
const mockServer = new WS('wss://localhost/websocket/');
|
const mockServer = new WS('wss://localhost/websocket/');
|
||||||
const inventories = [{ id: 1 }];
|
const inventories = [{ id: 1 }];
|
||||||
const fetch = jest.fn(() => []);
|
const fetchInventories = jest.fn(() => []);
|
||||||
|
const fetchInventoriesById = jest.fn(() => []);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = await mountWithContexts(
|
wrapper = await mountWithContexts(
|
||||||
<Test inventories={inventories} fetch={fetch} />
|
<Test
|
||||||
|
inventories={inventories}
|
||||||
|
fetchInventories={fetchInventories}
|
||||||
|
fetchInventoriesById={fetchInventoriesById}
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,7 +147,75 @@ describe('useWsInventories hook', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith([1]);
|
expect(fetchInventoriesById).toHaveBeenCalledWith([1]);
|
||||||
WS.clean();
|
});
|
||||||
|
|
||||||
|
test('should update inventory pending_deletion', async () => {
|
||||||
|
global.document.cookie = 'csrftoken=abc123';
|
||||||
|
const mockServer = new WS('wss://localhost/websocket/');
|
||||||
|
|
||||||
|
const inventories = [{ id: 1, pending_deletion: false }];
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = await mountWithContexts(
|
||||||
|
<Test inventories={inventories} qsConfig={QS_CONFIG} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await mockServer.connected;
|
||||||
|
await expect(mockServer).toReceiveMessage(
|
||||||
|
JSON.stringify({
|
||||||
|
xrftoken: 'abc123',
|
||||||
|
groups: {
|
||||||
|
inventories: ['status_changed'],
|
||||||
|
jobs: ['status_changed'],
|
||||||
|
control: ['limit_reached_1'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
act(() => {
|
||||||
|
mockServer.send(
|
||||||
|
JSON.stringify({
|
||||||
|
inventory_id: 1,
|
||||||
|
group_name: 'inventories',
|
||||||
|
status: 'pending_deletion',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.find('TestInner').prop('inventories')[0].pending_deletion
|
||||||
|
).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should refetch inventories after an inventory is deleted', async () => {
|
||||||
|
global.document.cookie = 'csrftoken=abc123';
|
||||||
|
const mockServer = new WS('wss://localhost/websocket/');
|
||||||
|
const inventories = [{ id: 1 }, { id: 2 }];
|
||||||
|
const fetchInventories = jest.fn(() => []);
|
||||||
|
const fetchInventoriesById = jest.fn(() => []);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = await mountWithContexts(
|
||||||
|
<Test
|
||||||
|
inventories={inventories}
|
||||||
|
fetchInventories={fetchInventories}
|
||||||
|
fetchInventoriesById={fetchInventoriesById}
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await mockServer.connected;
|
||||||
|
await act(async () => {
|
||||||
|
mockServer.send(
|
||||||
|
JSON.stringify({
|
||||||
|
inventory_id: 1,
|
||||||
|
group_name: 'inventories',
|
||||||
|
status: 'deleted',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchInventories).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ function ProjectListItem({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopyStart = useCallback(() => {
|
||||||
|
setIsDisabled(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopyFinish = useCallback(() => {
|
||||||
|
setIsDisabled(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const labelId = `check-action-${project.id}`;
|
const labelId = `check-action-${project.id}`;
|
||||||
return (
|
return (
|
||||||
<DataListItem
|
<DataListItem
|
||||||
@@ -182,8 +190,8 @@ function ProjectListItem({
|
|||||||
<CopyButton
|
<CopyButton
|
||||||
copyItem={copyProject}
|
copyItem={copyProject}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
onLoading={() => setIsDisabled(true)}
|
onCopyStart={handleCopyStart}
|
||||||
onDoneLoading={() => setIsDisabled(false)}
|
onCopyFinish={handleCopyFinish}
|
||||||
helperText={{
|
helperText={{
|
||||||
tooltip: i18n._(t`Copy Project`),
|
tooltip: i18n._(t`Copy Project`),
|
||||||
errorMessage: i18n._(t`Failed to copy project.`),
|
errorMessage: i18n._(t`Failed to copy project.`),
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ function TemplateListItem({
|
|||||||
await fetchTemplates();
|
await fetchTemplates();
|
||||||
}, [fetchTemplates, template.id, template.name, template.type]);
|
}, [fetchTemplates, template.id, template.name, template.type]);
|
||||||
|
|
||||||
|
const handleCopyStart = useCallback(() => {
|
||||||
|
setIsDisabled(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopyFinish = useCallback(() => {
|
||||||
|
setIsDisabled(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const missingResourceIcon =
|
const missingResourceIcon =
|
||||||
template.type === 'job_template' &&
|
template.type === 'job_template' &&
|
||||||
(!template.summary_fields.project ||
|
(!template.summary_fields.project ||
|
||||||
@@ -157,8 +165,8 @@ function TemplateListItem({
|
|||||||
errorMessage: i18n._(t`Failed to copy template.`),
|
errorMessage: i18n._(t`Failed to copy template.`),
|
||||||
}}
|
}}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
onLoading={() => setIsDisabled(true)}
|
onCopyStart={handleCopyStart}
|
||||||
onDoneLoading={() => setIsDisabled(false)}
|
onCopyFinish={handleCopyFinish}
|
||||||
copyItem={copyTemplate}
|
copyItem={copyTemplate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user