1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-26 16:25:06 +03:00

Adds support for pending deletion on inventory list

This commit is contained in:
mabashian 2020-08-28 14:25:37 -04:00
parent d6201d9eb6
commit 4b566e9388
6 changed files with 213 additions and 59 deletions

View File

@ -2,18 +2,24 @@ import React, { useContext, useEffect, useState } from 'react';
import {
func,
bool,
node,
number,
string,
arrayOf,
shape,
checkPropTypes,
} 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 { t } from '@lingui/macro';
import AlertModal from '../AlertModal';
import { KebabifiedContext } from '../../contexts/Kebabified';
const WarningMessage = styled(Alert)`
margin-top: 10px;
`;
const requireNameOrUsername = props => {
const { name, username } = props;
if (!name && !username) {
@ -64,6 +70,7 @@ function ToolbarDeleteButton({
pluralizedItemName,
errorMessage,
onDelete,
warningMessage,
i18n,
}) {
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
@ -171,6 +178,9 @@ function ToolbarDeleteButton({
<br />
</span>
))}
{warningMessage && (
<WarningMessage variant="warning" isInline title={warningMessage} />
)}
</AlertModal>
)}
</>
@ -182,11 +192,13 @@ ToolbarDeleteButton.propTypes = {
itemsToDelete: arrayOf(ItemToDelete).isRequired,
pluralizedItemName: string,
errorMessage: string,
warningMessage: node,
};
ToolbarDeleteButton.defaultProps = {
pluralizedItemName: 'Items',
errorMessage: '',
warningMessage: null,
};
export default withI18n()(ToolbarDeleteButton);

View File

@ -7,6 +7,7 @@ exports[`<ToolbarDeleteButton /> should render button 1`] = `
itemsToDelete={Array []}
onDelete={[Function]}
pluralizedItemName="Items"
warningMessage={null}
>
<Tooltip
content="Select a row to delete"

View File

@ -3,7 +3,6 @@ import { useLocation, useRouteMatch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core';
import { InventoriesAPI } from '../../../api';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import AlertModal from '../../../components/AlertModal';
@ -80,7 +79,13 @@ function InventoryList({ i18n }) {
},
[location.search] // eslint-disable-line react-hooks/exhaustive-deps
);
const inventories = useWsInventories(results, fetchInventoriesById);
const inventories = useWsInventories(
results,
fetchInventories,
fetchInventoriesById,
QS_CONFIG
);
const isAllSelected =
selected.length === inventories.length && selected.length > 0;
@ -94,9 +99,7 @@ function InventoryList({ i18n }) {
return Promise.all(selected.map(team => InventoriesAPI.destroy(team.id)));
}, [selected]),
{
qsConfig: QS_CONFIG,
allItemsSelected: isAllSelected,
fetchItems: fetchInventories,
}
);
@ -113,10 +116,12 @@ function InventoryList({ i18n }) {
};
const handleSelect = row => {
if (selected.some(s => s.id === row.id)) {
setSelected(selected.filter(s => s.id !== row.id));
} else {
setSelected(selected.concat(row));
if (!row.pending_deletion) {
if (selected.some(s => s.id === row.id)) {
setSelected(selected.filter(s => s.id !== row.id));
} else {
setSelected(selected.concat(row));
}
}
};
@ -183,6 +188,10 @@ function InventoryList({ i18n }) {
onDelete={handleInventoryDelete}
itemsToDelete={selected}
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 }
)}
/>,
]}
/>

View File

@ -8,6 +8,7 @@ import {
DataListItem,
DataListItemCells,
DataListItemRow,
Label,
Tooltip,
} from '@patternfly/react-core';
import { PencilAltIcon } from '@patternfly/react-icons';
@ -69,6 +70,7 @@ function InventoryListItem({
<DataListItemRow>
<DataListCheck
id={`select-inventory-${inventory.id}`}
isDisabled={inventory.pending_deletion}
checked={isSelected}
onChange={onSelect}
aria-labelledby={labelId}
@ -79,52 +81,63 @@ function InventoryListItem({
<SyncStatusIndicator status={syncStatus} />
</DataListCell>,
<DataListCell key="name">
<Link to={`${detailUrl}`}>
{inventory.pending_deletion ? (
<b>{inventory.name}</b>
</Link>
) : (
<Link to={`${detailUrl}`}>
<b>{inventory.name}</b>
</Link>
)}
</DataListCell>,
<DataListCell key="kind">
{inventory.kind === 'smart'
? i18n._(t`Smart Inventory`)
: i18n._(t`Inventory`)}
</DataListCell>,
inventory.pending_deletion && (
<DataListCell alignRight isFilled={false} key="pending-delete">
<Label color="red">{i18n._(t`Pending delete`)}</Label>
</DataListCell>
),
]}
/>
<DataListAction
aria-label="actions"
aria-labelledby={labelId}
id={labelId}
>
{inventory.summary_fields.user_capabilities.edit ? (
<Tooltip content={i18n._(t`Edit Inventory`)} position="top">
<Button
{!inventory.pending_deletion && (
<DataListAction
aria-label="actions"
aria-labelledby={labelId}
id={labelId}
>
{inventory.summary_fields.user_capabilities.edit ? (
<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}
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}
onLoading={() => setIsDisabled(true)}
onDoneLoading={() => setIsDisabled(false)}
helperText={{
tooltip: i18n._(t`Copy Inventory`),
errorMessage: i18n._(t`Failed to copy inventory.`),
}}
/>
)}
</DataListAction>
onLoading={() => setIsDisabled(true)}
onDoneLoading={() => setIsDisabled(false)}
helperText={{
tooltip: i18n._(t`Copy Inventory`),
errorMessage: i18n._(t`Failed to copy inventory.`),
}}
/>
)}
</DataListAction>
)}
</DataListItemRow>
</DataListItem>
);

View File

@ -1,11 +1,21 @@
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 useThrottle from '../../../util/useThrottle';
export default function useWsInventories(
initialInventories,
fetchInventoriesById
fetchInventories,
fetchInventoriesById,
qsConfig
) {
const location = useLocation();
const history = useHistory();
const [inventories, setInventories] = useState(initialInventories);
const [inventoriesToFetch, setInventoriesToFetch] = useState([]);
const throttledInventoriesToFetch = useThrottle(inventoriesToFetch, 5000);
@ -53,7 +63,8 @@ export default function useWsInventories(
function processWsMessage() {
if (
!lastMessage?.inventory_id ||
lastMessage.type !== 'inventory_update'
(lastMessage.type !== 'inventory_update' &&
lastMessage.group_name !== 'inventories')
) {
return;
}
@ -64,16 +75,41 @@ export default function useWsInventories(
return;
}
if (!['pending', 'waiting', 'running'].includes(lastMessage.status)) {
enqueueId(lastMessage.inventory_id);
return;
}
const inventory = inventories[index];
const updatedInventory = {
...inventory,
isSourceSyncRunning: true,
};
if (lastMessage.group_name === 'inventories') {
if (lastMessage.status === 'pending_deletion') {
updatedInventory.pending_deletion = true;
} else if (lastMessage.status === 'deleted') {
if (inventories.length === 1) {
const params = parseQueryString(qsConfig, location.search);
if (params.page > 1) {
const newParams = encodeNonDefaultQueryString(
qsConfig,
replaceParams(params, {
page: params.page - 1,
})
);
history.push(`${location.pathname}?${newParams}`);
}
} else {
fetchInventories();
}
} else {
return;
}
} else {
if (!['pending', 'waiting', 'running'].includes(lastMessage.status)) {
enqueueId(lastMessage.inventory_id);
return;
}
updatedInventory.isSourceSyncRunning = true;
}
setInventories([
...inventories.slice(0, index),
updatedInventory,

View File

@ -16,9 +16,19 @@ jest.mock('../../../util/useThrottle', () => ({
function TestInner() {
return <div />;
}
function Test({ inventories, fetch }) {
const syncedJobs = useWsInventories(inventories, fetch);
return <TestInner inventories={syncedJobs} />;
function Test({
inventories,
fetchInventories,
fetchInventoriesById,
qsConfig,
}) {
const syncedInventories = useWsInventories(
inventories,
fetchInventories,
fetchInventoriesById,
qsConfig
);
return <TestInner inventories={syncedInventories} />;
}
describe('useWsInventories hook', () => {
@ -105,10 +115,15 @@ describe('useWsInventories hook', () => {
global.document.cookie = 'csrftoken=abc123';
const mockServer = new WS('wss://localhost/websocket/');
const inventories = [{ id: 1 }];
const fetch = jest.fn(() => []);
const fetchInventories = jest.fn(() => []);
const fetchInventoriesById = jest.fn(() => []);
await act(async () => {
wrapper = await mountWithContexts(
<Test inventories={inventories} fetch={fetch} />
<Test
inventories={inventories}
fetchInventories={fetchInventories}
fetchInventoriesById={fetchInventoriesById}
/>
);
});
@ -123,7 +138,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} />);
});
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);
WS.clean();
});
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}
/>
);
});
await mockServer.connected;
await act(async () => {
mockServer.send(
JSON.stringify({
inventory_id: 1,
group_name: 'inventories',
status: 'deleted',
})
);
});
expect(fetchInventories).toHaveBeenCalled();
WS.clean();
});
});