Adds Inventory Groups routing --sort of

Adds Inventory Groups Add
Adds Inventory Groups Edit
Adds Inventory Groups Form
Adds api module for Groups
Adds placeholder file for InventoryGroupsList.  This was added to refine routing.  Tgere are no tests for this file yet.
This commit is contained in:
Alex Corey 2019-11-15 16:57:56 -05:00
parent 4dd4928aab
commit c997fcfc2c
19 changed files with 865 additions and 242 deletions

View File

@ -27,7 +27,7 @@ class Inventories extends Component {
};
}
setBreadCrumbConfig = inventory => {
setBreadCrumbConfig = (inventory, group) => {
const { i18n } = this.props;
if (!inventory) {
return;
@ -57,6 +57,13 @@ class Inventories extends Component {
),
[`/inventories/inventory/${inventory.id}/sources`]: i18n._(t`Sources`),
[`/inventories/inventory/${inventory.id}/groups`]: i18n._(t`Groups`),
[`/inventories/inventory/${inventory.id}/groups/add`]: i18n._(
t`Create New Group`
),
[`/inventories/inventory/${inventory.id}/groups/${group &&
group.id}/details`]: i18n._(t`Details`),
[`/inventories/inventory/${inventory.id}/groups/${group &&
group.id}/edit`]: `${group && group.name}`,
};
this.setState({ breadcrumbConfig });
};

View File

@ -123,7 +123,14 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
<Route
key="groups"
path="/inventories/inventory/:id/groups"
render={() => <InventoryGroups inventory={inventory} />}
render={() => (
<InventoryGroups
location={location}
match={match}
setBreadcrumb={setBreadcrumb}
inventory={inventory}
/>
)}
/>,
<Route
key="hosts"

View File

@ -0,0 +1,87 @@
import React, { useEffect, useState } from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import { Switch, Route, withRouter, Link, Redirect } from 'react-router-dom';
import { GroupsAPI } from '@api';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import InventoryGroupEdit from '../InventoryGroupEdit/InventoryGroupEdit';
import InventoryGroupDetail from '../InventoryGroupDetail/InventoryGroupDetail';
function InventoryGroups({ i18n, match, setBreadcrumb, inventory }) {
const [inventoryGroup, setInventoryGroup] = useState(null);
const [hasContentLoading, setContentLoading] = useState(true);
const [hasContentError, setHasContentError] = useState(false);
useEffect(() => {
const loadData = async () => {
try {
const { data } = await GroupsAPI.readDetail(match.params.groupId);
setInventoryGroup(data);
setBreadcrumb(inventory, data);
} catch (err) {
setHasContentError(err);
} finally {
setContentLoading(false);
}
};
loadData();
}, [match.params.groupId, setBreadcrumb, inventory]);
if (hasContentError) {
return <ContentError />;
}
if (hasContentLoading) {
return <ContentLoading />;
}
return (
<Switch>
<Redirect
from="/inventories/inventory/:id/groups/:groupId"
to="/inventories/inventory/:id/groups/:groupId/details"
exact
/>
{inventoryGroup && [
<Route
key="edit"
path="/inventories/inventory/:id/groups/:groupId/edit"
render={() => {
return <InventoryGroupEdit inventoryGroup={inventoryGroup} />;
}}
/>,
<Route
key="details"
path="/inventories/inventory/:id/groups/:groupId/details"
render={() => {
return <InventoryGroupDetail inventoryGroup={inventoryGroup} />;
}}
/>,
<Route
key="not-found"
path="*"
render={() =>
!hasContentLoading && (
<ContentError>
{match.params.id && (
<Link
to={`/inventories/inventory/${match.params.id}/details`}
>
{i18n._(t`View Inventory Details`)}
</Link>
)}
</ContentError>
)
}
/>,
]}
</Switch>
);
}
export { InventoryGroups as _InventoryGroups };
export default withI18n()(withRouter(InventoryGroups));

View File

@ -0,0 +1 @@
export { default } from './InventoryGroup';

View File

@ -0,0 +1,36 @@
import React, { useState, useEffect } from 'react';
import { withI18n } from '@lingui/react';
import { withRouter } from 'react-router-dom';
import { GroupsAPI } from '@api';
import ContentError from '@components/ContentError';
import InventoryGroupForm from '../InventoryGroupForm/InventoryGroupForm';
function InventoryGroupsAdd({ history, inventory, setBreadcrumb }) {
useEffect(() => setBreadcrumb(inventory), [inventory, setBreadcrumb]);
const [error, setError] = useState(null);
const handleSubmit = async values => {
values.inventory = inventory.id;
try {
const { data } = await GroupsAPI.create(values);
history.push(`/inventories/inventory/${inventory.id}/groups/${data.id}`);
} catch (err) {
setError(err);
}
};
const handleCancel = () => {
history.push(`/inventories/inventory/${inventory.id}/groups`);
};
if (error) {
return <ContentError />;
}
return (
<InventoryGroupForm
error={error}
handleCancel={handleCancel}
handleSubmit={handleSubmit}
/>
);
}
export default withI18n()(withRouter(InventoryGroupsAdd));
export { InventoryGroupsAdd as _InventoryGroupsAdd };

View File

@ -0,0 +1,60 @@
import React from 'react';
import { GroupsAPI } from '@api';
import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import InventoryGroupAdd from './InventoryGroupAdd';
jest.mock('@api');
describe('<InventoryGroupAdd />', () => {
let wrapper;
let history;
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/inventories/1/groups'],
});
await act(async () => {
wrapper = mountWithContexts(
<InventoryGroupAdd
setBreadcrumb={() => {}}
inventory={{ inventory: { id: 1 } }}
/>,
{
context: {
router: {
history,
},
},
}
);
});
});
afterEach(() => {
wrapper.unmount();
});
test('InventoryGroupEdit renders successfully', () => {
expect(wrapper.length).toBe(1);
});
test('cancel should navigate user to Inventory Groups List', async () => {
await act(async () => {
waitForElement(wrapper, 'isLoading', el => el.length === 0);
});
expect(history.location.pathname).toEqual('/inventories/1/groups');
});
test('handleSubmit should call api', async () => {
await act(async () => {
waitForElement(wrapper, 'isLoading', el => el.length === 0);
});
await act(async () => {
wrapper.find('InventoryGroupForm').prop('handleSubmit')({
name: 'Bar',
description: 'Ansible',
variables: 'ying: yang',
});
});
expect(GroupsAPI.create).toBeCalled();
});
});

View File

@ -0,0 +1 @@
export { default } from './InventoryGroupAdd';

View File

@ -0,0 +1,86 @@
import React, { useState } from 'react';
import { t } from '@lingui/macro';
import { CardBody, Button } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { withRouter } from 'react-router-dom';
import styled from 'styled-components';
import { VariablesInput } from '@components/CodeMirrorInput';
import ContentError from '@components/ContentError';
import { GroupsAPI } from '@api';
import { DetailList, Detail } from '@components/DetailList';
const ActionButtonWrapper = styled.div`
display: flex;
justify-content: flex-end;
margin-top: 20px;
& > :not(:first-child) {
margin-left: 20px;
}
`;
function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) {
const [error, setError] = useState(false);
const handleDelete = async () => {
try {
await GroupsAPI.destroy(inventoryGroup.id);
history.push(`/inventories/inventory/${match.params.id}/groups`);
} catch (err) {
setError(err);
}
};
if (error) {
return <ContentError />;
}
return (
<CardBody style={{ paddingTop: '20px' }}>
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={inventoryGroup.name} />
<Detail
label={i18n._(t`Description`)}
value={inventoryGroup.description}
/>
</DetailList>
<VariablesInput
css="margin: 20px 0"
id="inventoryGroup-variables"
readOnly
value={inventoryGroup.variables}
rows={4}
label={i18n._(t`Variables`)}
/>
<DetailList>
<Detail
label={i18n._(t`Created`)}
value={`${inventoryGroup.created} by ${inventoryGroup.summary_fields.created_by.username}`}
/>
<Detail
label={i18n._(t`Modified`)}
value={`${inventoryGroup.modified} by ${inventoryGroup.summary_fields.modified_by.username}`}
/>
</DetailList>
<ActionButtonWrapper>
<Button
variant="primary"
aria-label={i18n._(t`Edit`)}
onClick={() =>
history.push(
`/inventories/inventory/${match.params.id}/groups/${inventoryGroup.id}/edit`
)
}
>
{i18n._(t`Edit`)}
</Button>
<Button
variant="danger"
aria-label={i18n._(t`Delete`)}
onClick={handleDelete}
>
{i18n._(t`Delete`)}
</Button>
</ActionButtonWrapper>
</CardBody>
);
}
export default withI18n()(withRouter(InventoryGroupDetail));

View File

@ -0,0 +1,78 @@
import React from 'react';
import { GroupsAPI } from '@api';
import { MemoryRouter, Route } from 'react-router-dom';
import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import InventoryGroupDetail from './InventoryGroupDetail';
jest.mock('@api');
const inventoryGroup = {
name: 'Foo',
description: 'Bar',
variables: 'bizz: buzz',
id: 1,
created: '10:00',
modified: '12:00',
summary_fields: {
created_by: {
username: 'James',
},
modified_by: {
username: 'Bond',
},
},
};
describe('<InventoryGroupDetail />', () => {
let wrapper;
beforeEach(async () => {
await act(async () => {
wrapper = mountWithContexts(
<MemoryRouter
initialEntries={['/inventories/inventory/1/groups/1/edit']}
>
<Route
path="/inventories/inventory/:id/groups/:groupId"
component={() => (
<InventoryGroupDetail inventoryGroup={inventoryGroup} />
)}
/>
</MemoryRouter>
);
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
});
afterEach(() => {
wrapper.unmount();
});
test('InventoryGroupDetail renders successfully', () => {
expect(wrapper.length).toBe(1);
});
test('should call api to delete the group', () => {
wrapper.find('button[aria-label="Delete"]').simulate('click');
expect(GroupsAPI.destroy).toBeCalledWith(1);
});
test('should navigate user to edit form on edit button click', async () => {
wrapper.find('button[aria-label="Edit"]').prop('onClick');
expect(
wrapper
.find('Router')
.at(1)
.prop('history').location.pathname
).toEqual('/inventories/inventory/1/groups/1/edit');
});
test('details shoudld render with the proper values', () => {
expect(wrapper.find('Detail[label="Name"]').prop('value')).toBe('Foo');
expect(wrapper.find('Detail[label="Description"]').prop('value')).toBe(
'Bar'
);
expect(wrapper.find('Detail[label="Created"]').prop('value')).toBe(
'10:00 by James'
);
expect(wrapper.find('Detail[label="Modified"]').prop('value')).toBe(
'12:00 by Bond'
);
expect(wrapper.find('VariablesInput').prop('value')).toBe('bizz: buzz');
});
});

View File

@ -0,0 +1 @@
export { default } from './InventoryGroupDetail';

View File

@ -0,0 +1,33 @@
import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
import { withRouter } from 'react-router-dom';
import { GroupsAPI } from '@api';
import InventoryGroupForm from '../InventoryGroupForm/InventoryGroupForm';
function InventoryGroupEdit({ history, inventoryGroup, inventory, match }) {
const [error, setError] = useState(null);
const handleSubmit = async values => {
try {
await GroupsAPI.update(match.params.groupId, values);
} catch (err) {
setError(err);
} finally {
history.push(`/inventories/inventory/${inventory.id}/groups`);
}
};
const handleCancel = () => {
history.push(`/inventories/inventory/${inventory.id}/groups`);
};
return (
<InventoryGroupForm
error={error}
group={inventoryGroup}
handleCancel={handleCancel}
handleSubmit={handleSubmit}
/>
);
}
export default withI18n()(withRouter(InventoryGroupEdit));
export { InventoryGroupEdit as _InventoryGroupEdit };

View File

@ -0,0 +1,64 @@
import React from 'react';
import { GroupsAPI } from '@api';
import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import InventoryGroupEdit from './InventoryGroupEdit';
jest.mock('@api');
GroupsAPI.readDetail.mockResolvedValue({
data: {
name: 'Foo',
description: 'Bar',
variables: 'bizz: buzz',
},
});
describe('<InventoryGroupEdit />', () => {
let wrapper;
let history;
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/inventories/1/groups'],
});
await act(async () => {
wrapper = mountWithContexts(
<InventoryGroupEdit
setBreadcrumb={jest.fn()}
inventory={{ inventory: { id: 1 } }}
/>,
{
context: {
router: {
history,
route: {
match: {
params: { groupId: 13 },
},
},
},
},
}
);
});
});
afterEach(() => {
wrapper.unmount();
});
test('InventoryGroupEdit renders successfully', () => {
expect(wrapper.length).toBe(1);
});
test('cancel should navigate user to Inventory Groups List', async () => {
await waitForElement(wrapper, 'isLoading', el => el.length === 0);
expect(history.location.pathname).toEqual('/inventories/1/groups');
});
test('handleSubmit should call api', async () => {
await waitForElement(wrapper, 'isLoading', el => el.length === 0);
wrapper.find('InventoryGroupForm').prop('handleSubmit')({
name: 'Bar',
description: 'Ansible',
variables: 'ying: yang',
});
expect(GroupsAPI.update).toBeCalled();
});
});

View File

@ -0,0 +1 @@
export { default } from './InventoryGroupEdit';

View File

@ -0,0 +1,78 @@
import React from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { Formik } from 'formik';
import { Form, Card, CardBody, CardHeader } from '@patternfly/react-core';
import { t } from '@lingui/macro';
import CardCloseButton from '@components/CardCloseButton';
import FormRow from '@components/FormRow';
import FormField from '@components/FormField';
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
import { VariablesField } from '@components/CodeMirrorInput';
import { required } from '@util/validators';
function InventoryGroupForm({
i18n,
error,
group = {},
handleSubmit,
handleCancel,
match,
}) {
const initialValues = {
name: group.name || '',
description: group.description || '',
variables: group.variables || '---',
};
return (
<Card className="awx-c-card">
<CardHeader>
<CardCloseButton
linkTo={`/inventories/inventory/${match.params.id}/groups`}
/>
</CardHeader>
<CardBody>
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
render={formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormRow css="grid-template-columns: repeat(auto-fit, minmax(300px, 500px));">
<FormField
id="inventoryGroup-name"
name="name"
type="text"
label={i18n._(t`Name`)}
validate={required(null, i18n)}
isRequired
/>
<FormField
id="inventoryGroup-description"
name="description"
type="text"
label={i18n._(t`Description`)}
/>
</FormRow>
<FormRow>
<VariablesField
id="host-variables"
name="variables"
label={i18n._(t`Variables`)}
/>
</FormRow>
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
/>
{error ? <div>error</div> : null}
</Form>
)}
/>
</CardBody>
</Card>
);
}
export default withI18n()(withRouter(InventoryGroupForm));

View File

@ -0,0 +1,34 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import InventoryGroupForm from './InventoryGroupForm';
const group = {
id: 1,
name: 'Foo',
description: 'Bar',
variables: 'ying: false',
};
describe('<InventoryGroupForm />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(
<InventoryGroupForm
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
group={group}
/>
);
});
afterEach(() => {
wrapper.unmount();
});
test('initially renders successfully', () => {
expect(wrapper.length).toBe(1);
});
test('should render values for the fields that have them', () => {
expect(wrapper.length).toBe(1);
expect(wrapper.find("FormGroup[label='Name']").length).toBe(1);
expect(wrapper.find("FormGroup[label='Description']").length).toBe(1);
expect(wrapper.find("VariablesField[label='Variables']").length).toBe(1);
});
});

View File

@ -0,0 +1 @@
export { default } from './InventoryGroupForm';

View File

@ -27,7 +27,7 @@ function InventoryGroupItem({
onSelect,
}) {
const labelId = `check-action-${group.id}`;
const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/detail`;
const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/details`;
const editUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/edit`;
return (

View File

@ -1,250 +1,50 @@
import React, { useState, useEffect } from 'react';
import { TrashAltIcon } from '@patternfly/react-icons';
import { withRouter } from 'react-router-dom';
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { getQSConfig, parseQueryString } from '@util/qs';
import { InventoriesAPI, GroupsAPI } from '@api';
import { Button, Tooltip } from '@patternfly/react-core';
import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail';
import DataListToolbar from '@components/DataListToolbar';
import PaginatedDataList, {
ToolbarAddButton,
} from '@components/PaginatedDataList';
import styled from 'styled-components';
import InventoryGroupItem from './InventoryGroupItem';
import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal';
const QS_CONFIG = getQSConfig('host', {
page: 1,
page_size: 20,
order_by: 'name',
});
import { Switch, Route, withRouter } from 'react-router-dom';
const DeleteButton = styled(Button)`
padding: 5px 8px;
import InventoryGroupAdd from './InventoryGroupAdd/InventoryGroupAdd';
&:hover {
background-color: #d9534f;
color: white;
}
&[disabled] {
color: var(--pf-c-button--m-plain--Color);
pointer-events: initial;
cursor: not-allowed;
}
`;
function cannotDelete(item) {
return !item.summary_fields.user_capabilities.delete;
}
const useModal = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
function toggleModal() {
setIsModalOpen(!isModalOpen);
}
return {
isModalOpen,
toggleModal,
};
};
function InventoryGroups({ i18n, location, match }) {
const [actions, setActions] = useState(null);
const [contentError, setContentError] = useState(null);
const [deletionError, setDeletionError] = useState(null);
const [groupCount, setGroupCount] = useState(0);
const [groups, setGroups] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [selected, setSelected] = useState([]);
const { isModalOpen, toggleModal } = useModal();
const inventoryId = match.params.id;
const fetchGroups = (id, queryString) => {
const params = parseQueryString(QS_CONFIG, queryString);
return InventoriesAPI.readGroups(id, params);
};
useEffect(() => {
async function fetchData() {
try {
const [
{
data: { count, results },
},
{
data: { actions: optionActions },
},
] = await Promise.all([
fetchGroups(inventoryId, location.search),
InventoriesAPI.readGroupsOptions(inventoryId),
]);
setGroups(results);
setGroupCount(count);
setActions(optionActions);
} catch (error) {
setContentError(error);
} finally {
setIsLoading(false);
}
}
fetchData();
}, [inventoryId, location]);
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...groups] : []);
};
const handleSelect = row => {
if (selected.some(s => s.id === row.id)) {
setSelected(selected.filter(s => s.id !== row.id));
} else {
setSelected(selected.concat(row));
}
};
const renderTooltip = () => {
const itemsUnableToDelete = selected
.filter(cannotDelete)
.map(item => item.name)
.join(', ');
if (selected.some(cannotDelete)) {
return (
<div>
{i18n._(
t`You do not have permission to delete the following Groups: ${itemsUnableToDelete}`
)}
</div>
);
}
if (selected.length) {
return i18n._(t`Delete`);
}
return i18n._(t`Select a row to delete`);
};
const handleDelete = async option => {
setIsLoading(true);
try {
/* eslint-disable no-await-in-loop, no-restricted-syntax */
/* Delete groups sequentially to avoid api integrity errors */
for (const group of selected) {
if (option === 'delete') {
await GroupsAPI.destroy(+group.id);
} else if (option === 'promote') {
await InventoriesAPI.promoteGroup(inventoryId, +group.id);
}
}
/* eslint-enable no-await-in-loop, no-restricted-syntax */
} catch (error) {
setDeletionError(error);
}
toggleModal();
try {
const {
data: { count, results },
} = await fetchGroups(inventoryId, location.search);
setGroups(results);
setGroupCount(count);
} catch (error) {
setContentError(error);
}
setIsLoading(false);
};
const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const isAllSelected =
selected.length > 0 && selected.length === groups.length;
import InventoryGroup from './InventoryGroup/InventoryGroup';
import InventoryGroupsList from './InventoryGroupsList';
function InventoryGroups({ setBreadcrumb, inventory, location, match }) {
return (
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
items={groups}
itemCount={groupCount}
qsConfig={QS_CONFIG}
renderItem={item => (
<InventoryGroupItem
key={item.id}
group={item}
inventoryId={inventoryId}
isSelected={selected.some(row => row.id === item.id)}
onSelect={() => handleSelect(item)}
/>
)}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
qsConfig={QS_CONFIG}
additionalControls={[
<Tooltip content={renderTooltip()} position="top" key="delete">
<div>
<DeleteButton
variant="plain"
aria-label={i18n._(t`Delete`)}
onClick={toggleModal}
isDisabled={
selected.length === 0 || selected.some(cannotDelete)
}
>
<TrashAltIcon />
</DeleteButton>
</div>
</Tooltip>,
canAdd && (
<ToolbarAddButton
key="add"
linkTo={`/inventories/inventory/${inventoryId}/groups/add`}
/>
),
]}
/>
)}
emptyStateControls={
canAdd && (
<ToolbarAddButton
key="add"
linkTo={`/inventories/inventory/${inventoryId}/groups/add`}
<Switch>
{[
<Route
key="list"
path="/inventories/inventory/:id/groups"
render={() => {
return <InventoryGroupsList location={location} match={match} />;
}}
/>,
<Route
key="add"
path="/inventories/inventory/:id/groups/add"
render={() => {
return (
<InventoryGroupAdd
setBreadcrumb={setBreadcrumb}
inventory={inventory}
/>
);
}}
/>,
<Route
key="details"
path="/inventories/inventory/:id/groups/:groupId/"
render={() => (
<InventoryGroup
inventory={inventory}
setBreadcrumb={setBreadcrumb}
/>
)
}
/>
{deletionError && (
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)}
>
{i18n._(t`Failed to delete one or more groups.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
)}
<InventoryGroupsDeleteModal
groups={selected}
isModalOpen={isModalOpen}
onClose={toggleModal}
onDelete={handleDelete}
/>
</>
)}
/>,
]}
</Switch>
);
}
export { InventoryGroups as _InventoryGroups };
export default withI18n()(withRouter(InventoryGroups));

View File

@ -0,0 +1,248 @@
import React, { useState, useEffect } from 'react';
import { TrashAltIcon } from '@patternfly/react-icons';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { getQSConfig, parseQueryString } from '@util/qs';
import { InventoriesAPI, GroupsAPI } from '@api';
import { Button, Tooltip } from '@patternfly/react-core';
import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail';
import DataListToolbar from '@components/DataListToolbar';
import PaginatedDataList, {
ToolbarAddButton,
} from '@components/PaginatedDataList';
import styled from 'styled-components';
import InventoryGroupItem from './InventoryGroupItem';
import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal';
const QS_CONFIG = getQSConfig('host', {
page: 1,
page_size: 20,
order_by: 'name',
});
const DeleteButton = styled(Button)`
padding: 5px 8px;
&:hover {
background-color: #d9534f;
color: white;
}
&[disabled] {
color: var(--pf-c-button--m-plain--Color);
pointer-events: initial;
cursor: not-allowed;
}
`;
function cannotDelete(item) {
return !item.summary_fields.user_capabilities.delete;
}
const useModal = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
function toggleModal() {
setIsModalOpen(!isModalOpen);
}
return {
isModalOpen,
toggleModal,
};
};
function InventoryGroupsList({ i18n, location, match }) {
const [actions, setActions] = useState(null);
const [contentError, setContentError] = useState(null);
const [deletionError, setDeletionError] = useState(null);
const [groupCount, setGroupCount] = useState(0);
const [groups, setGroups] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [selected, setSelected] = useState([]);
const { isModalOpen, toggleModal } = useModal();
const inventoryId = match.params.id;
const fetchGroups = (id, queryString) => {
const params = parseQueryString(QS_CONFIG, queryString);
return InventoriesAPI.readGroups(id, params);
};
useEffect(() => {
async function fetchData() {
try {
const [
{
data: { count, results },
},
{
data: { actions: optionActions },
},
] = await Promise.all([
fetchGroups(inventoryId, location.search),
InventoriesAPI.readGroupsOptions(inventoryId),
]);
setGroups(results);
setGroupCount(count);
setActions(optionActions);
} catch (error) {
setContentError(error);
} finally {
setIsLoading(false);
}
}
fetchData();
}, [inventoryId, location]);
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...groups] : []);
};
const handleSelect = row => {
if (selected.some(s => s.id === row.id)) {
setSelected(selected.filter(s => s.id !== row.id));
} else {
setSelected(selected.concat(row));
}
};
const renderTooltip = () => {
const itemsUnableToDelete = selected
.filter(cannotDelete)
.map(item => item.name)
.join(', ');
if (selected.some(cannotDelete)) {
return (
<div>
{i18n._(
t`You do not have permission to delete the following Groups: ${itemsUnableToDelete}`
)}
</div>
);
}
if (selected.length) {
return i18n._(t`Delete`);
}
return i18n._(t`Select a row to delete`);
};
const handleDelete = async option => {
setIsLoading(true);
try {
/* eslint-disable no-await-in-loop, no-restricted-syntax */
/* Delete groups sequentially to avoid api integrity errors */
for (const group of selected) {
if (option === 'delete') {
await GroupsAPI.destroy(+group.id);
} else if (option === 'promote') {
await InventoriesAPI.promoteGroup(inventoryId, +group.id);
}
}
/* eslint-enable no-await-in-loop, no-restricted-syntax */
} catch (error) {
setDeletionError(error);
}
toggleModal();
try {
const {
data: { count, results },
} = await fetchGroups(inventoryId, location.search);
setGroups(results);
setGroupCount(count);
} catch (error) {
setContentError(error);
}
setIsLoading(false);
};
const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const isAllSelected =
selected.length > 0 && selected.length === groups.length;
return (
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
items={groups}
itemCount={groupCount}
qsConfig={QS_CONFIG}
renderItem={item => (
<InventoryGroupItem
key={item.id}
group={item}
inventoryId={inventoryId}
isSelected={selected.some(row => row.id === item.id)}
onSelect={() => handleSelect(item)}
/>
)}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
qsConfig={QS_CONFIG}
additionalControls={[
<Tooltip content={renderTooltip()} position="top" key="delete">
<div>
<DeleteButton
variant="plain"
aria-label={i18n._(t`Delete`)}
onClick={toggleModal}
isDisabled={
selected.length === 0 || selected.some(cannotDelete)
}
>
<TrashAltIcon />
</DeleteButton>
</div>
</Tooltip>,
canAdd && (
<ToolbarAddButton
key="add"
linkTo={`/inventories/inventory/${inventoryId}/groups/add`}
/>
),
]}
/>
)}
emptyStateControls={
canAdd && (
<ToolbarAddButton
key="add"
linkTo={`/inventories/inventory/${inventoryId}/groups/add`}
/>
)
}
/>
{deletionError && (
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)}
>
{i18n._(t`Failed to delete one or more groups.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
)}
<InventoryGroupsDeleteModal
groups={selected}
isModalOpen={isModalOpen}
onClose={toggleModal}
onDelete={handleDelete}
/>
</>
);
}
export default withI18n()(withRouter(InventoryGroupsList));