mirror of
https://github.com/ansible/awx.git
synced 2026-04-14 14:39:26 -02:30
Merge pull request #8729 from keithjgrant/6189-list-tables
Create PaginatedTable component Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
8274
awx/ui_next/package-lock.json
generated
8274
awx/ui_next/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
|||||||
"@patternfly/patternfly": "4.70.2",
|
"@patternfly/patternfly": "4.70.2",
|
||||||
"@patternfly/react-core": "4.84.3",
|
"@patternfly/react-core": "4.84.3",
|
||||||
"@patternfly/react-icons": "4.7.22",
|
"@patternfly/react-icons": "4.7.22",
|
||||||
|
"@patternfly/react-table": "^4.19.15",
|
||||||
"ansi-to-html": "^0.6.11",
|
"ansi-to-html": "^0.6.11",
|
||||||
"axios": "^0.18.1",
|
"axios": "^0.18.1",
|
||||||
"codemirror": "^5.47.0",
|
"codemirror": "^5.47.0",
|
||||||
|
|||||||
@@ -93,9 +93,11 @@ function DataListToolbar({
|
|||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
/>
|
/>
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
<ToolbarItem>
|
{sortColumns && (
|
||||||
<Sort qsConfig={qsConfig} columns={sortColumns} onSort={onSort} />
|
<ToolbarItem>
|
||||||
</ToolbarItem>
|
<Sort qsConfig={qsConfig} columns={sortColumns} onSort={onSort} />
|
||||||
|
</ToolbarItem>
|
||||||
|
)}
|
||||||
</ToolbarToggleGroup>
|
</ToolbarToggleGroup>
|
||||||
{showExpandCollapse && (
|
{showExpandCollapse && (
|
||||||
<ToolbarGroup>
|
<ToolbarGroup>
|
||||||
@@ -157,7 +159,7 @@ DataListToolbar.propTypes = {
|
|||||||
searchColumns: SearchColumns.isRequired,
|
searchColumns: SearchColumns.isRequired,
|
||||||
searchableKeys: PropTypes.arrayOf(PropTypes.string),
|
searchableKeys: PropTypes.arrayOf(PropTypes.string),
|
||||||
relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
|
relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
|
||||||
sortColumns: SortColumns.isRequired,
|
sortColumns: SortColumns,
|
||||||
showSelectAll: PropTypes.bool,
|
showSelectAll: PropTypes.bool,
|
||||||
isAllSelected: PropTypes.bool,
|
isAllSelected: PropTypes.bool,
|
||||||
isCompact: PropTypes.bool,
|
isCompact: PropTypes.bool,
|
||||||
@@ -174,6 +176,7 @@ DataListToolbar.defaultProps = {
|
|||||||
itemCount: 0,
|
itemCount: 0,
|
||||||
searchableKeys: [],
|
searchableKeys: [],
|
||||||
relatedSearchableKeys: [],
|
relatedSearchableKeys: [],
|
||||||
|
sortColumns: null,
|
||||||
clearAllFilters: null,
|
clearAllFilters: null,
|
||||||
showSelectAll: false,
|
showSelectAll: false,
|
||||||
isAllSelected: false,
|
isAllSelected: false,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useContext } from 'react';
|
import React, { useState, useEffect, useContext } from 'react';
|
||||||
import { arrayOf, func, object, string } from 'prop-types';
|
import { arrayOf, func, shape, string, oneOfType, number } from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Button, Tooltip, DropdownItem } from '@patternfly/react-core';
|
import { Button, Tooltip, DropdownItem } from '@patternfly/react-core';
|
||||||
@@ -149,7 +149,20 @@ DisassociateButton.defaultProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
DisassociateButton.propTypes = {
|
DisassociateButton.propTypes = {
|
||||||
itemsToDisassociate: arrayOf(object),
|
itemsToDisassociate: oneOfType([
|
||||||
|
arrayOf(
|
||||||
|
shape({
|
||||||
|
id: number.isRequired,
|
||||||
|
name: string.isRequired,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
arrayOf(
|
||||||
|
shape({
|
||||||
|
id: number.isRequired,
|
||||||
|
hostname: string.isRequired,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
]),
|
||||||
modalNote: string,
|
modalNote: string,
|
||||||
modalTitle: string,
|
modalTitle: string,
|
||||||
onDisassociate: func.isRequired,
|
onDisassociate: func.isRequired,
|
||||||
|
|||||||
@@ -147,13 +147,14 @@ ListHeader.propTypes = {
|
|||||||
searchColumns: SearchColumns.isRequired,
|
searchColumns: SearchColumns.isRequired,
|
||||||
searchableKeys: PropTypes.arrayOf(PropTypes.string),
|
searchableKeys: PropTypes.arrayOf(PropTypes.string),
|
||||||
relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
|
relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
|
||||||
sortColumns: SortColumns.isRequired,
|
sortColumns: SortColumns,
|
||||||
renderToolbar: PropTypes.func,
|
renderToolbar: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
ListHeader.defaultProps = {
|
ListHeader.defaultProps = {
|
||||||
renderToolbar: props => <DataListToolbar {...props} />,
|
renderToolbar: props => <DataListToolbar {...props} />,
|
||||||
searchableKeys: [],
|
searchableKeys: [],
|
||||||
|
sortColumns: null,
|
||||||
relatedSearchableKeys: [],
|
relatedSearchableKeys: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -279,8 +279,8 @@ function HostFilterLookup({
|
|||||||
numChips={5}
|
numChips={5}
|
||||||
totalChips={chips[key]?.chips?.length || 0}
|
totalChips={chips[key]?.chips?.length || 0}
|
||||||
>
|
>
|
||||||
{chips[key]?.chips?.map((chip, index) => (
|
{chips[key]?.chips?.map(chip => (
|
||||||
<Chip key={index} isReadOnly>
|
<Chip key={chip.key} isReadOnly>
|
||||||
{chip.node}
|
{chip.node}
|
||||||
</Chip>
|
</Chip>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { arrayOf, string, func, object, bool } from 'prop-types';
|
import { arrayOf, string, func, bool } from 'prop-types';
|
||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter } 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 { FormGroup } from '@patternfly/react-core';
|
import { FormGroup } from '@patternfly/react-core';
|
||||||
import { InstanceGroupsAPI } from '../../api';
|
import { InstanceGroupsAPI } from '../../api';
|
||||||
|
import { InstanceGroup } from '../../types';
|
||||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||||
import Popover from '../Popover';
|
import Popover from '../Popover';
|
||||||
import OptionsList from '../OptionsList';
|
import OptionsList from '../OptionsList';
|
||||||
@@ -120,7 +121,7 @@ function InstanceGroupsLookup(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
InstanceGroupsLookup.propTypes = {
|
InstanceGroupsLookup.propTypes = {
|
||||||
value: arrayOf(object).isRequired,
|
value: arrayOf(InstanceGroup).isRequired,
|
||||||
tooltip: string,
|
tooltip: string,
|
||||||
onChange: func.isRequired,
|
onChange: func.isRequired,
|
||||||
className: string,
|
className: string,
|
||||||
|
|||||||
21
awx/ui_next/src/components/PaginatedTable/ActionItem.jsx
Normal file
21
awx/ui_next/src/components/PaginatedTable/ActionItem.jsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import 'styled-components/macro';
|
||||||
|
import React from 'react';
|
||||||
|
import { Tooltip } from '@patternfly/react-core';
|
||||||
|
|
||||||
|
export default function ActionItem({ column, tooltip, visible, children }) {
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
css={`
|
||||||
|
grid-column: ${column};
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Tooltip content={tooltip} position="top">
|
||||||
|
{children}
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import ActionItem from './ActionItem';
|
||||||
|
|
||||||
|
describe('<ActionItem />', () => {
|
||||||
|
test('should render child with tooltip', async () => {
|
||||||
|
const wrapper = shallow(
|
||||||
|
<ActionItem columns={1} tooltip="a tooltip" visible>
|
||||||
|
foo
|
||||||
|
</ActionItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
const tooltip = wrapper.find('Tooltip');
|
||||||
|
expect(tooltip.prop('content')).toEqual('a tooltip');
|
||||||
|
expect(tooltip.prop('children')).toEqual('foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render null if not visible', async () => {
|
||||||
|
const wrapper = shallow(
|
||||||
|
<ActionItem columns={1} tooltip="foo">
|
||||||
|
<div>foo</div>
|
||||||
|
</ActionItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(wrapper.find('Tooltip')).toHaveLength(0);
|
||||||
|
expect(wrapper.find('div')).toHaveLength(0);
|
||||||
|
expect(wrapper.text()).toEqual('');
|
||||||
|
});
|
||||||
|
});
|
||||||
40
awx/ui_next/src/components/PaginatedTable/ActionsTd.jsx
Normal file
40
awx/ui_next/src/components/PaginatedTable/ActionsTd.jsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import 'styled-components/macro';
|
||||||
|
import React from 'react';
|
||||||
|
import { Td } from '@patternfly/react-table';
|
||||||
|
import styled, { css } from 'styled-components';
|
||||||
|
|
||||||
|
const ActionsGrid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
${props => {
|
||||||
|
const columns = '40px '.repeat(props.numActions || 1);
|
||||||
|
return css`
|
||||||
|
grid-template-columns: ${columns};
|
||||||
|
`;
|
||||||
|
}}
|
||||||
|
`;
|
||||||
|
ActionsGrid.displayName = 'ActionsGrid';
|
||||||
|
|
||||||
|
export default function ActionsTd({ children, ...props }) {
|
||||||
|
const numActions = children.length || 1;
|
||||||
|
const width = numActions * 40;
|
||||||
|
return (
|
||||||
|
<Td
|
||||||
|
css={`
|
||||||
|
text-align: right;
|
||||||
|
--pf-c-table--cell--Width: ${width}px;
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ActionsGrid numActions={numActions}>
|
||||||
|
{React.Children.map(children, (child, i) =>
|
||||||
|
React.cloneElement(child, {
|
||||||
|
column: i + 1,
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ActionsGrid>
|
||||||
|
</Td>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx
Normal file
66
awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useLocation, useHistory } from 'react-router-dom';
|
||||||
|
import { Thead, Tr, Th as PFTh } from '@patternfly/react-table';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import {
|
||||||
|
encodeNonDefaultQueryString,
|
||||||
|
parseQueryString,
|
||||||
|
replaceParams,
|
||||||
|
} from '../../util/qs';
|
||||||
|
|
||||||
|
const Th = styled(PFTh)`
|
||||||
|
--pf-c-table--cell--Overflow: initial;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function HeaderRow({ qsConfig, children }) {
|
||||||
|
const location = useLocation();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const params = parseQueryString(qsConfig, location.search);
|
||||||
|
|
||||||
|
const onSort = (key, order) => {
|
||||||
|
const newParams = replaceParams(params, {
|
||||||
|
order_by: order === 'asc' ? key : `-${key}`,
|
||||||
|
page: null,
|
||||||
|
});
|
||||||
|
const encodedParams = encodeNonDefaultQueryString(qsConfig, newParams);
|
||||||
|
history.push(
|
||||||
|
encodedParams
|
||||||
|
? `${location.pathname}?${encodedParams}`
|
||||||
|
: location.pathname
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortKey = params.order_by?.replace('-', '');
|
||||||
|
const sortBy = {
|
||||||
|
index: sortKey || qsConfig.defaultParams?.order_by,
|
||||||
|
direction: params.order_by?.startsWith('-') ? 'desc' : 'asc',
|
||||||
|
};
|
||||||
|
|
||||||
|
// empty first Th aligns with checkboxes in table rows
|
||||||
|
return (
|
||||||
|
<Thead>
|
||||||
|
<Tr>
|
||||||
|
<Th />
|
||||||
|
{React.Children.map(children, child =>
|
||||||
|
React.cloneElement(child, {
|
||||||
|
onSort,
|
||||||
|
sortBy,
|
||||||
|
columnIndex: child.props.sortKey,
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeaderCell({ sortKey, onSort, sortBy, columnIndex, children }) {
|
||||||
|
const sort = sortKey
|
||||||
|
? {
|
||||||
|
onSort: (event, key, order) => onSort(sortKey, order),
|
||||||
|
sortBy,
|
||||||
|
columnIndex,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
return <Th sort={sort}>{children}</Th>;
|
||||||
|
}
|
||||||
65
awx/ui_next/src/components/PaginatedTable/HeaderRow.test.jsx
Normal file
65
awx/ui_next/src/components/PaginatedTable/HeaderRow.test.jsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||||
|
import HeaderRow, { HeaderCell } from './HeaderRow';
|
||||||
|
|
||||||
|
describe('<HeaderRow />', () => {
|
||||||
|
const qsConfig = {
|
||||||
|
defaultParams: {
|
||||||
|
order_by: 'one',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
test('should render cells', async () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<HeaderRow qsConfig={qsConfig}>
|
||||||
|
<HeaderCell sortKey="one">One</HeaderCell>
|
||||||
|
<HeaderCell>Two</HeaderCell>
|
||||||
|
</HeaderRow>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cells = wrapper.find('Th');
|
||||||
|
expect(cells).toHaveLength(3);
|
||||||
|
expect(cells.at(1).text()).toEqual('One');
|
||||||
|
expect(cells.at(2).text()).toEqual('Two');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should provide sort controls', async () => {
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/list'],
|
||||||
|
});
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<HeaderRow qsConfig={qsConfig}>
|
||||||
|
<HeaderCell sortKey="one">One</HeaderCell>
|
||||||
|
<HeaderCell>Two</HeaderCell>
|
||||||
|
</HeaderRow>
|
||||||
|
</table>,
|
||||||
|
{ context: { router: { history } } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const cell = wrapper.find('Th').at(1);
|
||||||
|
cell.prop('sort').onSort({}, '', 'desc');
|
||||||
|
expect(history.location.search).toEqual('?order_by=-one');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not sort cells without a sortKey', async () => {
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/list'],
|
||||||
|
});
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<HeaderRow qsConfig={qsConfig}>
|
||||||
|
<HeaderCell sortKey="one">One</HeaderCell>
|
||||||
|
<HeaderCell>Two</HeaderCell>
|
||||||
|
</HeaderRow>
|
||||||
|
</table>,
|
||||||
|
{ context: { router: { history } } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const cell = wrapper.find('Th').at(2);
|
||||||
|
expect(cell.prop('sort')).toEqual(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
184
awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx
Normal file
184
awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import React, { Fragment } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { TableComposable, Tbody } from '@patternfly/react-table';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import ListHeader from '../ListHeader';
|
||||||
|
import ContentEmpty from '../ContentEmpty';
|
||||||
|
import ContentError from '../ContentError';
|
||||||
|
import ContentLoading from '../ContentLoading';
|
||||||
|
import Pagination from '../Pagination';
|
||||||
|
import DataListToolbar from '../DataListToolbar';
|
||||||
|
|
||||||
|
import {
|
||||||
|
encodeNonDefaultQueryString,
|
||||||
|
parseQueryString,
|
||||||
|
replaceParams,
|
||||||
|
} from '../../util/qs';
|
||||||
|
import { QSConfig, SearchColumns } from '../../types';
|
||||||
|
|
||||||
|
function PaginatedTable({
|
||||||
|
contentError,
|
||||||
|
hasContentLoading,
|
||||||
|
emptyStateControls,
|
||||||
|
items,
|
||||||
|
itemCount,
|
||||||
|
qsConfig,
|
||||||
|
headerRow,
|
||||||
|
renderRow,
|
||||||
|
toolbarSearchColumns,
|
||||||
|
toolbarSearchableKeys,
|
||||||
|
toolbarRelatedSearchableKeys,
|
||||||
|
pluralizedItemName,
|
||||||
|
showPageSizeOptions,
|
||||||
|
i18n,
|
||||||
|
renderToolbar,
|
||||||
|
}) {
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const pushHistoryState = params => {
|
||||||
|
const { pathname } = history.location;
|
||||||
|
const encodedParams = encodeNonDefaultQueryString(qsConfig, params);
|
||||||
|
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetPage = (event, pageNumber) => {
|
||||||
|
const oldParams = parseQueryString(qsConfig, history.location.search);
|
||||||
|
pushHistoryState(replaceParams(oldParams, { page: pageNumber }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetPageSize = (event, pageSize, page) => {
|
||||||
|
const oldParams = parseQueryString(qsConfig, history.location.search);
|
||||||
|
pushHistoryState(replaceParams(oldParams, { page_size: pageSize, page }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchColumns = toolbarSearchColumns.length
|
||||||
|
? toolbarSearchColumns
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
name: i18n._(t`Name`),
|
||||||
|
key: 'name',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const queryParams = parseQueryString(qsConfig, history.location.search);
|
||||||
|
|
||||||
|
const dataListLabel = i18n._(t`${pluralizedItemName} List`);
|
||||||
|
const emptyContentMessage = i18n._(
|
||||||
|
t`Please add ${pluralizedItemName} to populate this list `
|
||||||
|
);
|
||||||
|
const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `);
|
||||||
|
|
||||||
|
let Content;
|
||||||
|
if (hasContentLoading && items.length <= 0) {
|
||||||
|
Content = <ContentLoading />;
|
||||||
|
} else if (contentError) {
|
||||||
|
Content = <ContentError error={contentError} />;
|
||||||
|
} else if (items.length <= 0) {
|
||||||
|
Content = (
|
||||||
|
<ContentEmpty title={emptyContentTitle} message={emptyContentMessage} />
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Content = (
|
||||||
|
<TableComposable aria-label={dataListLabel}>
|
||||||
|
{headerRow}
|
||||||
|
<Tbody>{items.map(renderRow)}</Tbody>
|
||||||
|
</TableComposable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToolbarPagination = (
|
||||||
|
<Pagination
|
||||||
|
isCompact
|
||||||
|
dropDirection="down"
|
||||||
|
itemCount={itemCount}
|
||||||
|
page={queryParams.page || 1}
|
||||||
|
perPage={queryParams.page_size}
|
||||||
|
perPageOptions={
|
||||||
|
showPageSizeOptions
|
||||||
|
? [
|
||||||
|
{ title: '5', value: 5 },
|
||||||
|
{ title: '10', value: 10 },
|
||||||
|
{ title: '20', value: 20 },
|
||||||
|
{ title: '50', value: 50 },
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
onSetPage={handleSetPage}
|
||||||
|
onPerPageSelect={handleSetPageSize}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<ListHeader
|
||||||
|
itemCount={itemCount}
|
||||||
|
renderToolbar={renderToolbar}
|
||||||
|
emptyStateControls={emptyStateControls}
|
||||||
|
searchColumns={searchColumns}
|
||||||
|
searchableKeys={toolbarSearchableKeys}
|
||||||
|
relatedSearchableKeys={toolbarRelatedSearchableKeys}
|
||||||
|
qsConfig={qsConfig}
|
||||||
|
pagination={ToolbarPagination}
|
||||||
|
/>
|
||||||
|
{Content}
|
||||||
|
{items.length ? (
|
||||||
|
<Pagination
|
||||||
|
variant="bottom"
|
||||||
|
itemCount={itemCount}
|
||||||
|
page={queryParams.page || 1}
|
||||||
|
perPage={queryParams.page_size}
|
||||||
|
perPageOptions={
|
||||||
|
showPageSizeOptions
|
||||||
|
? [
|
||||||
|
{ title: '5', value: 5 },
|
||||||
|
{ title: '10', value: 10 },
|
||||||
|
{ title: '20', value: 20 },
|
||||||
|
{ title: '50', value: 50 },
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
onSetPage={handleSetPage}
|
||||||
|
onPerPageSelect={handleSetPageSize}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Item = PropTypes.shape({
|
||||||
|
id: PropTypes.number.isRequired,
|
||||||
|
url: PropTypes.string.isRequired,
|
||||||
|
name: PropTypes.string,
|
||||||
|
});
|
||||||
|
|
||||||
|
PaginatedTable.propTypes = {
|
||||||
|
items: PropTypes.arrayOf(Item).isRequired,
|
||||||
|
itemCount: PropTypes.number.isRequired,
|
||||||
|
pluralizedItemName: PropTypes.string,
|
||||||
|
qsConfig: QSConfig.isRequired,
|
||||||
|
renderRow: PropTypes.func.isRequired,
|
||||||
|
toolbarSearchColumns: SearchColumns,
|
||||||
|
toolbarSearchableKeys: PropTypes.arrayOf(PropTypes.string),
|
||||||
|
toolbarRelatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
|
||||||
|
showPageSizeOptions: PropTypes.bool,
|
||||||
|
renderToolbar: PropTypes.func,
|
||||||
|
hasContentLoading: PropTypes.bool,
|
||||||
|
contentError: PropTypes.shape(),
|
||||||
|
};
|
||||||
|
|
||||||
|
PaginatedTable.defaultProps = {
|
||||||
|
hasContentLoading: false,
|
||||||
|
contentError: null,
|
||||||
|
toolbarSearchColumns: [],
|
||||||
|
toolbarSearchableKeys: [],
|
||||||
|
toolbarRelatedSearchableKeys: [],
|
||||||
|
pluralizedItemName: 'Items',
|
||||||
|
showPageSizeOptions: true,
|
||||||
|
renderToolbar: props => <DataListToolbar {...props} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export { PaginatedTable as _PaginatedTable };
|
||||||
|
export default withI18n()(PaginatedTable);
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||||
|
import PaginatedTable from './PaginatedTable';
|
||||||
|
|
||||||
|
const mockData = [
|
||||||
|
{ id: 1, name: 'one', url: '/org/team/1' },
|
||||||
|
{ id: 2, name: 'two', url: '/org/team/2' },
|
||||||
|
{ id: 3, name: 'three', url: '/org/team/3' },
|
||||||
|
{ id: 4, name: 'four', url: '/org/team/4' },
|
||||||
|
{ id: 5, name: 'five', url: '/org/team/5' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const qsConfig = {
|
||||||
|
namespace: 'item',
|
||||||
|
defaultParams: { page: 1, page_size: 5, order_by: 'name' },
|
||||||
|
integerFields: ['page', 'page_size'],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('<PaginatedTable />', () => {
|
||||||
|
test('should render item rows', () => {
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/organizations/1/teams'],
|
||||||
|
});
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<PaginatedTable
|
||||||
|
items={mockData}
|
||||||
|
itemCount={7}
|
||||||
|
queryParams={{
|
||||||
|
page: 1,
|
||||||
|
page_size: 5,
|
||||||
|
order_by: 'name',
|
||||||
|
}}
|
||||||
|
qsConfig={qsConfig}
|
||||||
|
renderRow={item => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>{item.name}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
{ context: { router: { history } } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = wrapper.find('tr');
|
||||||
|
expect(rows).toHaveLength(5);
|
||||||
|
expect(rows.at(0).text()).toEqual('one');
|
||||||
|
expect(rows.at(1).text()).toEqual('two');
|
||||||
|
expect(rows.at(2).text()).toEqual('three');
|
||||||
|
expect(rows.at(3).text()).toEqual('four');
|
||||||
|
expect(rows.at(4).text()).toEqual('five');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate page when changes', () => {
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/organizations/1/teams'],
|
||||||
|
});
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<PaginatedTable
|
||||||
|
items={mockData}
|
||||||
|
itemCount={7}
|
||||||
|
queryParams={{
|
||||||
|
page: 1,
|
||||||
|
page_size: 5,
|
||||||
|
order_by: 'name',
|
||||||
|
}}
|
||||||
|
qsConfig={qsConfig}
|
||||||
|
renderRow={() => null}
|
||||||
|
/>,
|
||||||
|
{ context: { router: { history } } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const pagination = wrapper.find('Pagination').at(1);
|
||||||
|
pagination.prop('onSetPage')(null, 2);
|
||||||
|
expect(history.location.search).toEqual('?item.page=2');
|
||||||
|
wrapper.update();
|
||||||
|
pagination.prop('onSetPage')(null, 1);
|
||||||
|
// since page = 1 is the default, that should be strip out of the search
|
||||||
|
expect(history.location.search).toEqual('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to page when page size changes', () => {
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/organizations/1/teams'],
|
||||||
|
});
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<PaginatedTable
|
||||||
|
items={mockData}
|
||||||
|
itemCount={7}
|
||||||
|
queryParams={{
|
||||||
|
page: 1,
|
||||||
|
page_size: 5,
|
||||||
|
order_by: 'name',
|
||||||
|
}}
|
||||||
|
qsConfig={qsConfig}
|
||||||
|
renderRow={() => null}
|
||||||
|
/>,
|
||||||
|
{ context: { router: { history } } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const pagination = wrapper.find('Pagination').at(1);
|
||||||
|
pagination.prop('onPerPageSelect')(null, 25, 2);
|
||||||
|
expect(history.location.search).toEqual('?item.page=2&item.page_size=25');
|
||||||
|
wrapper.update();
|
||||||
|
// since page_size = 5 is the default, that should be strip out of the search
|
||||||
|
pagination.prop('onPerPageSelect')(null, 5, 2);
|
||||||
|
expect(history.location.search).toEqual('?item.page=2');
|
||||||
|
});
|
||||||
|
});
|
||||||
4
awx/ui_next/src/components/PaginatedTable/index.js
Normal file
4
awx/ui_next/src/components/PaginatedTable/index.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { default } from './PaginatedTable';
|
||||||
|
export { default as ActionsTd } from './ActionsTd';
|
||||||
|
export { default as HeaderRow, HeaderCell } from './HeaderRow';
|
||||||
|
export { default as ActionItem } from './ActionItem';
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { shape, string, number, arrayOf, node, oneOfType } from 'prop-types';
|
import { shape, string, number, arrayOf, node, oneOfType } from 'prop-types';
|
||||||
import { Tab, Tabs, TabTitleText } from '@patternfly/react-core';
|
import { Tab, Tabs, TabTitleText } from '@patternfly/react-core';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
function RoutedTabs(props) {
|
function RoutedTabs(props) {
|
||||||
const { tabsArray } = props;
|
const { tabsArray } = props;
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const getActiveTabId = () => {
|
const getActiveTabId = () => {
|
||||||
const match = tabsArray.find(tab => tab.link === history.location.pathname);
|
const match = tabsArray.find(tab => tab.link === location.pathname);
|
||||||
if (match) {
|
if (match) {
|
||||||
return match.id;
|
return match.id;
|
||||||
}
|
}
|
||||||
const subpathMatch = tabsArray.find(tab =>
|
const subpathMatch = tabsArray.find(tab =>
|
||||||
history.location.pathname.startsWith(tab.link)
|
location.pathname.startsWith(tab.link)
|
||||||
);
|
);
|
||||||
if (subpathMatch) {
|
if (subpathMatch) {
|
||||||
return subpathMatch.id;
|
return subpathMatch.id;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React, { Fragment } from 'react';
|
||||||
import { arrayOf, object } from 'prop-types';
|
import { arrayOf } from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { Link as _Link } from 'react-router-dom';
|
import { Link as _Link } from 'react-router-dom';
|
||||||
import { Tooltip } from '@patternfly/react-core';
|
import { Tooltip } from '@patternfly/react-core';
|
||||||
@@ -8,6 +8,7 @@ import { t } from '@lingui/macro';
|
|||||||
import StatusIcon from '../StatusIcon';
|
import StatusIcon from '../StatusIcon';
|
||||||
import { formatDateString } from '../../util/dates';
|
import { formatDateString } from '../../util/dates';
|
||||||
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
|
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
|
||||||
|
import { Job } from '../../types';
|
||||||
|
|
||||||
/* eslint-disable react/jsx-pascal-case */
|
/* eslint-disable react/jsx-pascal-case */
|
||||||
const Link = styled(props => <_Link {...props} />)`
|
const Link = styled(props => <_Link {...props} />)`
|
||||||
@@ -52,7 +53,7 @@ const Sparkline = ({ i18n, jobs }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Sparkline.propTypes = {
|
Sparkline.propTypes = {
|
||||||
jobs: arrayOf(object),
|
jobs: arrayOf(Job),
|
||||||
};
|
};
|
||||||
Sparkline.defaultProps = {
|
Sparkline.defaultProps = {
|
||||||
jobs: [],
|
jobs: [],
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
SyncAltIcon,
|
SyncAltIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
|
MinusCircleIcon,
|
||||||
} from '@patternfly/react-icons';
|
} from '@patternfly/react-icons';
|
||||||
import styled, { keyframes } from 'styled-components';
|
import styled, { keyframes } from 'styled-components';
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ const colors = {
|
|||||||
running: 'blue',
|
running: 'blue',
|
||||||
pending: 'blue',
|
pending: 'blue',
|
||||||
waiting: 'grey',
|
waiting: 'grey',
|
||||||
|
disabled: 'grey',
|
||||||
canceled: 'orange',
|
canceled: 'orange',
|
||||||
};
|
};
|
||||||
const icons = {
|
const icons = {
|
||||||
@@ -42,6 +44,7 @@ const icons = {
|
|||||||
running: RunningIcon,
|
running: RunningIcon,
|
||||||
pending: ClockIcon,
|
pending: ClockIcon,
|
||||||
waiting: ClockIcon,
|
waiting: ClockIcon,
|
||||||
|
disabled: MinusCircleIcon,
|
||||||
canceled: ExclamationTriangleIcon,
|
canceled: ExclamationTriangleIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,6 +69,7 @@ StatusLabel.propTypes = {
|
|||||||
'running',
|
'running',
|
||||||
'pending',
|
'pending',
|
||||||
'waiting',
|
'waiting',
|
||||||
|
'disabled',
|
||||||
'canceled',
|
'canceled',
|
||||||
]).isRequired,
|
]).isRequired,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
|||||||
import AlertModal from '../../../components/AlertModal';
|
import AlertModal from '../../../components/AlertModal';
|
||||||
import DatalistToolbar from '../../../components/DataListToolbar';
|
import DatalistToolbar from '../../../components/DataListToolbar';
|
||||||
import ErrorDetail from '../../../components/ErrorDetail';
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
import PaginatedDataList, {
|
import { ToolbarDeleteButton } from '../../../components/PaginatedDataList';
|
||||||
ToolbarDeleteButton,
|
import PaginatedTable, {
|
||||||
} from '../../../components/PaginatedDataList';
|
HeaderRow,
|
||||||
|
HeaderCell,
|
||||||
|
} from '../../../components/PaginatedTable';
|
||||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||||
import useWsInventories from './useWsInventories';
|
import useWsInventories from './useWsInventories';
|
||||||
import AddDropDownButton from '../../../components/AddDropDownButton';
|
import AddDropDownButton from '../../../components/AddDropDownButton';
|
||||||
@@ -149,17 +151,17 @@ function InventoryList({ i18n }) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<Card>
|
<Card>
|
||||||
<PaginatedDataList
|
<PaginatedTable
|
||||||
contentError={contentError}
|
contentError={contentError}
|
||||||
hasContentLoading={hasContentLoading}
|
hasContentLoading={hasContentLoading}
|
||||||
items={inventories}
|
items={inventories}
|
||||||
itemCount={itemCount}
|
itemCount={itemCount}
|
||||||
pluralizedItemName={i18n._(t`Inventories`)}
|
pluralizedItemName={i18n._(t`Inventories`)}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
onRowClick={handleSelect}
|
|
||||||
toolbarSearchColumns={[
|
toolbarSearchColumns={[
|
||||||
{
|
{
|
||||||
name: i18n._(t`Name`),
|
name: i18n._(t`Name`),
|
||||||
@@ -187,6 +189,18 @@ function InventoryList({ i18n }) {
|
|||||||
]}
|
]}
|
||||||
toolbarSearchableKeys={searchableKeys}
|
toolbarSearchableKeys={searchableKeys}
|
||||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||||
|
headerRow={
|
||||||
|
<HeaderRow qsConfig={QS_CONFIG}>
|
||||||
|
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
|
||||||
|
<HeaderCell>{i18n._(t`Status`)}</HeaderCell>
|
||||||
|
<HeaderCell>{i18n._(t`Type`)}</HeaderCell>
|
||||||
|
<HeaderCell>{i18n._(t`Organization`)}</HeaderCell>
|
||||||
|
<HeaderCell>{i18n._(t`Groups`)}</HeaderCell>
|
||||||
|
<HeaderCell>{i18n._(t`Hosts`)}</HeaderCell>
|
||||||
|
<HeaderCell>{i18n._(t`Sources`)}</HeaderCell>
|
||||||
|
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
|
||||||
|
</HeaderRow>
|
||||||
|
}
|
||||||
renderToolbar={props => (
|
renderToolbar={props => (
|
||||||
<DatalistToolbar
|
<DatalistToolbar
|
||||||
{...props}
|
{...props}
|
||||||
@@ -209,11 +223,12 @@ function InventoryList({ i18n }) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderItem={inventory => (
|
renderRow={(inventory, index) => (
|
||||||
<InventoryListItem
|
<InventoryListItem
|
||||||
key={inventory.id}
|
key={inventory.id}
|
||||||
value={inventory.name}
|
value={inventory.name}
|
||||||
inventory={inventory}
|
inventory={inventory}
|
||||||
|
rowIndex={index}
|
||||||
fetchInventories={fetchInventories}
|
fetchInventories={fetchInventories}
|
||||||
detailUrl={
|
detailUrl={
|
||||||
inventory.kind === 'smart'
|
inventory.kind === 'smart'
|
||||||
|
|||||||
@@ -1,50 +1,21 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { string, bool, func } from 'prop-types';
|
import { string, bool, func } from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import {
|
import { Button, Label } from '@patternfly/react-core';
|
||||||
Button,
|
import { Tr, Td } from '@patternfly/react-table';
|
||||||
DataListAction as _DataListAction,
|
|
||||||
DataListCheck,
|
|
||||||
DataListItem,
|
|
||||||
DataListItemCells,
|
|
||||||
DataListItemRow,
|
|
||||||
Label,
|
|
||||||
Tooltip,
|
|
||||||
Badge as PFBadge,
|
|
||||||
} from '@patternfly/react-core';
|
|
||||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import styled from 'styled-components';
|
|
||||||
import { timeOfDay } from '../../../util/dates';
|
import { timeOfDay } from '../../../util/dates';
|
||||||
import { InventoriesAPI } from '../../../api';
|
import { InventoriesAPI } from '../../../api';
|
||||||
import { Inventory } from '../../../types';
|
import { Inventory } from '../../../types';
|
||||||
import DataListCell from '../../../components/DataListCell';
|
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
|
||||||
import CopyButton from '../../../components/CopyButton';
|
import CopyButton from '../../../components/CopyButton';
|
||||||
import SyncStatusIndicator from '../../../components/SyncStatusIndicator';
|
import StatusLabel from '../../../components/StatusLabel';
|
||||||
|
|
||||||
const DataListAction = styled(_DataListAction)`
|
|
||||||
align-items: center;
|
|
||||||
display: grid;
|
|
||||||
grid-gap: 16px;
|
|
||||||
grid-template-columns: repeat(2, 40px);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Badge = styled(PFBadge)`
|
|
||||||
margin-left: 8px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ListGroup = styled.div`
|
|
||||||
margin-left: 8px;
|
|
||||||
display: inline-block;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const OrgLabel = styled.b`
|
|
||||||
margin-right: 20px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
function InventoryListItem({
|
function InventoryListItem({
|
||||||
inventory,
|
inventory,
|
||||||
|
rowIndex,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSelect,
|
onSelect,
|
||||||
detailUrl,
|
detailUrl,
|
||||||
@@ -85,108 +56,84 @@ function InventoryListItem({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataListItem
|
<Tr id={inventory.id} aria-labelledby={labelId}>
|
||||||
key={inventory.id}
|
<Td
|
||||||
aria-labelledby={labelId}
|
select={{
|
||||||
id={`${inventory.id}`}
|
rowIndex,
|
||||||
>
|
isSelected,
|
||||||
<DataListItemRow>
|
onSelect,
|
||||||
<DataListCheck
|
}}
|
||||||
id={`select-inventory-${inventory.id}`}
|
dataLabel={i18n._(t`Selected`)}
|
||||||
isDisabled={inventory.pending_deletion}
|
/>
|
||||||
checked={isSelected}
|
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||||
onChange={onSelect}
|
{inventory.pending_deletion ? (
|
||||||
aria-labelledby={labelId}
|
<b>{inventory.name}</b>
|
||||||
/>
|
) : (
|
||||||
<DataListItemCells
|
<Link to={`${detailUrl}`}>
|
||||||
dataListCells={[
|
<b>{inventory.name}</b>
|
||||||
<DataListCell key="sync-status" isIcon>
|
</Link>
|
||||||
{inventory.kind !== 'smart' && (
|
|
||||||
<SyncStatusIndicator status={syncStatus} />
|
|
||||||
)}
|
|
||||||
</DataListCell>,
|
|
||||||
<DataListCell key="name">
|
|
||||||
{inventory.pending_deletion ? (
|
|
||||||
<b>{inventory.name}</b>
|
|
||||||
) : (
|
|
||||||
<Link to={`${detailUrl}`}>
|
|
||||||
<b>{inventory.name}</b>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</DataListCell>,
|
|
||||||
<DataListCell key="kind">
|
|
||||||
{inventory.kind === 'smart'
|
|
||||||
? i18n._(t`Smart Inventory`)
|
|
||||||
: i18n._(t`Inventory`)}
|
|
||||||
</DataListCell>,
|
|
||||||
<DataListCell key="organization">
|
|
||||||
<OrgLabel>{i18n._(t`Organization`)}</OrgLabel>
|
|
||||||
<Link
|
|
||||||
to={`/organizations/${inventory.summary_fields.organization.id}/details`}
|
|
||||||
>
|
|
||||||
{inventory.summary_fields.organization.name}
|
|
||||||
</Link>
|
|
||||||
</DataListCell>,
|
|
||||||
<DataListCell key="groups-hosts-sources-counts">
|
|
||||||
<ListGroup>
|
|
||||||
{i18n._(t`Groups`)}
|
|
||||||
<Badge isRead>{inventory.total_groups}</Badge>
|
|
||||||
</ListGroup>
|
|
||||||
<ListGroup>
|
|
||||||
{i18n._(t`Hosts`)}
|
|
||||||
<Badge isRead>{inventory.total_hosts}</Badge>
|
|
||||||
</ListGroup>
|
|
||||||
<ListGroup>
|
|
||||||
{i18n._(t`Sources`)}
|
|
||||||
<Badge isRead>{inventory.total_inventory_sources}</Badge>
|
|
||||||
</ListGroup>
|
|
||||||
</DataListCell>,
|
|
||||||
inventory.pending_deletion && (
|
|
||||||
<DataListCell alignRight isFilled={false} key="pending-delete">
|
|
||||||
<Label color="red">{i18n._(t`Pending delete`)}</Label>
|
|
||||||
</DataListCell>
|
|
||||||
),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{!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}
|
|
||||||
onCopyStart={handleCopyStart}
|
|
||||||
onCopyFinish={handleCopyFinish}
|
|
||||||
helperText={{
|
|
||||||
tooltip: i18n._(t`Copy Inventory`),
|
|
||||||
errorMessage: i18n._(t`Failed to copy inventory.`),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DataListAction>
|
|
||||||
)}
|
)}
|
||||||
</DataListItemRow>
|
</Td>
|
||||||
</DataListItem>
|
<Td dataLabel={i18n._(t`Status`)}>
|
||||||
|
{inventory.kind !== 'smart' && <StatusLabel status={syncStatus} />}
|
||||||
|
</Td>
|
||||||
|
<Td dataLabel={i18n._(t`Type`)}>
|
||||||
|
{inventory.kind === 'smart'
|
||||||
|
? i18n._(t`Smart Inventory`)
|
||||||
|
: i18n._(t`Inventory`)}
|
||||||
|
</Td>
|
||||||
|
<Td key="organization" dataLabel={i18n._(t`Organization`)}>
|
||||||
|
<Link
|
||||||
|
to={`/organizations/${inventory?.summary_fields?.organization?.id}/details`}
|
||||||
|
>
|
||||||
|
{inventory?.summary_fields?.organization?.name}
|
||||||
|
</Link>
|
||||||
|
</Td>
|
||||||
|
<Td dataLabel={i18n._(t`Groups`)}>{inventory.total_groups}</Td>
|
||||||
|
<Td dataLabel={i18n._(t`Hosts`)}>{inventory.total_hosts}</Td>
|
||||||
|
<Td dataLabel={i18n._(t`Sources`)}>
|
||||||
|
{inventory.total_inventory_sources}
|
||||||
|
</Td>
|
||||||
|
{inventory.pending_deletion ? (
|
||||||
|
<Td dataLabel={i18n._(t`Groups`)}>
|
||||||
|
<Label color="red">{i18n._(t`Pending delete`)}</Label>
|
||||||
|
</Td>
|
||||||
|
) : (
|
||||||
|
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||||
|
<ActionItem
|
||||||
|
visible={inventory.summary_fields.user_capabilities.edit}
|
||||||
|
tooltip={i18n._(t`Edit Inventory`)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</ActionItem>
|
||||||
|
<ActionItem
|
||||||
|
visible={inventory.summary_fields.user_capabilities.copy}
|
||||||
|
tooltip={i18n._(t`Copy Inventory`)}
|
||||||
|
>
|
||||||
|
<CopyButton
|
||||||
|
copyItem={copyInventory}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
onCopyStart={handleCopyStart}
|
||||||
|
onCopyFinish={handleCopyFinish}
|
||||||
|
helperText={{
|
||||||
|
tooltip: i18n._(t`Copy Inventory`),
|
||||||
|
errorMessage: i18n._(t`Failed to copy inventory.`),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ActionItem>
|
||||||
|
</ActionsTd>
|
||||||
|
)}
|
||||||
|
</Tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export default withI18n()(InventoryListItem);
|
export default withI18n()(InventoryListItem);
|
||||||
|
|||||||
@@ -9,145 +9,167 @@ jest.mock('../../../api/models/Inventories');
|
|||||||
describe('<InventoryListItem />', () => {
|
describe('<InventoryListItem />', () => {
|
||||||
test('initially renders succesfully', () => {
|
test('initially renders succesfully', () => {
|
||||||
mountWithContexts(
|
mountWithContexts(
|
||||||
<InventoryListItem
|
<table>
|
||||||
inventory={{
|
<tbody>
|
||||||
id: 1,
|
<InventoryListItem
|
||||||
name: 'Inventory',
|
inventory={{
|
||||||
summary_fields: {
|
|
||||||
organization: {
|
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Default',
|
name: 'Inventory',
|
||||||
},
|
summary_fields: {
|
||||||
user_capabilities: {
|
organization: {
|
||||||
edit: true,
|
id: 1,
|
||||||
},
|
name: 'Default',
|
||||||
},
|
},
|
||||||
}}
|
user_capabilities: {
|
||||||
detailUrl="/inventories/inventory/1"
|
edit: true,
|
||||||
isSelected
|
},
|
||||||
onSelect={() => {}}
|
},
|
||||||
/>
|
}}
|
||||||
|
detailUrl="/inventories/inventory/1"
|
||||||
|
isSelected
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should render prompt list item data', () => {
|
test('should render prompt list item data', () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<InventoryListItem
|
<table>
|
||||||
inventory={{
|
<tbody>
|
||||||
id: 1,
|
<InventoryListItem
|
||||||
name: 'Inventory',
|
inventory={{
|
||||||
kind: '',
|
|
||||||
summary_fields: {
|
|
||||||
organization: {
|
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Default',
|
name: 'Inventory',
|
||||||
},
|
kind: '',
|
||||||
user_capabilities: {
|
summary_fields: {
|
||||||
edit: true,
|
organization: {
|
||||||
},
|
id: 1,
|
||||||
},
|
name: 'Default',
|
||||||
}}
|
},
|
||||||
detailUrl="/inventories/inventory/1"
|
user_capabilities: {
|
||||||
isSelected
|
edit: true,
|
||||||
onSelect={() => {}}
|
},
|
||||||
/>
|
},
|
||||||
|
}}
|
||||||
|
detailUrl="/inventories/inventory/1"
|
||||||
|
isSelected
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
);
|
);
|
||||||
expect(wrapper.find('SyncStatusIndicator').length).toBe(1);
|
expect(wrapper.find('StatusLabel').length).toBe(1);
|
||||||
expect(
|
expect(
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListCell')
|
.find('Td')
|
||||||
.at(1)
|
.at(1)
|
||||||
.text()
|
.text()
|
||||||
).toBe('Inventory');
|
).toBe('Inventory');
|
||||||
expect(
|
expect(
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListCell')
|
.find('Td')
|
||||||
.at(2)
|
.at(2)
|
||||||
.text()
|
.text()
|
||||||
|
).toBe('Disabled');
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.find('Td')
|
||||||
|
.at(3)
|
||||||
|
.text()
|
||||||
).toBe('Inventory');
|
).toBe('Inventory');
|
||||||
expect(
|
expect(
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListCell')
|
.find('Td')
|
||||||
.at(3)
|
|
||||||
.text()
|
|
||||||
).toBe('OrganizationDefault');
|
|
||||||
expect(
|
|
||||||
wrapper
|
|
||||||
.find('DataListCell')
|
|
||||||
.at(4)
|
.at(4)
|
||||||
.text()
|
.text()
|
||||||
).toBe('GroupsHostsSources');
|
).toBe('Default');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('edit button shown to users with edit capabilities', () => {
|
test('edit button shown to users with edit capabilities', () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<InventoryListItem
|
<table>
|
||||||
inventory={{
|
<tbody>
|
||||||
id: 1,
|
<InventoryListItem
|
||||||
name: 'Inventory',
|
inventory={{
|
||||||
summary_fields: {
|
|
||||||
organization: {
|
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Default',
|
name: 'Inventory',
|
||||||
},
|
summary_fields: {
|
||||||
user_capabilities: {
|
organization: {
|
||||||
edit: true,
|
id: 1,
|
||||||
},
|
name: 'Default',
|
||||||
},
|
},
|
||||||
}}
|
user_capabilities: {
|
||||||
detailUrl="/inventories/inventory/1"
|
edit: true,
|
||||||
isSelected
|
},
|
||||||
onSelect={() => {}}
|
},
|
||||||
/>
|
}}
|
||||||
|
detailUrl="/inventories/inventory/1"
|
||||||
|
isSelected
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
);
|
);
|
||||||
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
|
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('edit button hidden from users without edit capabilities', () => {
|
test('edit button hidden from users without edit capabilities', () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<InventoryListItem
|
<table>
|
||||||
inventory={{
|
<tbody>
|
||||||
id: 1,
|
<InventoryListItem
|
||||||
name: 'Inventory',
|
inventory={{
|
||||||
summary_fields: {
|
|
||||||
organization: {
|
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Default',
|
name: 'Inventory',
|
||||||
},
|
summary_fields: {
|
||||||
user_capabilities: {
|
organization: {
|
||||||
edit: false,
|
id: 1,
|
||||||
},
|
name: 'Default',
|
||||||
},
|
},
|
||||||
}}
|
user_capabilities: {
|
||||||
detailUrl="/inventories/inventory/1"
|
edit: false,
|
||||||
isSelected
|
},
|
||||||
onSelect={() => {}}
|
},
|
||||||
/>
|
}}
|
||||||
|
detailUrl="/inventories/inventory/1"
|
||||||
|
isSelected
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
);
|
);
|
||||||
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should call api to copy inventory', async () => {
|
test('should call api to copy inventory', async () => {
|
||||||
InventoriesAPI.copy.mockResolvedValue();
|
InventoriesAPI.copy.mockResolvedValue();
|
||||||
|
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<InventoryListItem
|
<table>
|
||||||
inventory={{
|
<tbody>
|
||||||
id: 1,
|
<InventoryListItem
|
||||||
name: 'Inventory',
|
inventory={{
|
||||||
summary_fields: {
|
|
||||||
organization: {
|
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Default',
|
name: 'Inventory',
|
||||||
},
|
summary_fields: {
|
||||||
user_capabilities: {
|
organization: {
|
||||||
edit: false,
|
id: 1,
|
||||||
copy: true,
|
name: 'Default',
|
||||||
},
|
},
|
||||||
},
|
user_capabilities: {
|
||||||
}}
|
edit: false,
|
||||||
detailUrl="/inventories/inventory/1"
|
copy: true,
|
||||||
isSelected
|
},
|
||||||
onSelect={() => {}}
|
},
|
||||||
/>
|
}}
|
||||||
|
detailUrl="/inventories/inventory/1"
|
||||||
|
isSelected
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
);
|
);
|
||||||
|
|
||||||
await act(async () =>
|
await act(async () =>
|
||||||
@@ -161,25 +183,29 @@ describe('<InventoryListItem />', () => {
|
|||||||
InventoriesAPI.copy.mockRejectedValue(new Error());
|
InventoriesAPI.copy.mockRejectedValue(new Error());
|
||||||
|
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<InventoryListItem
|
<table>
|
||||||
inventory={{
|
<tbody>
|
||||||
id: 1,
|
<InventoryListItem
|
||||||
name: 'Inventory',
|
inventory={{
|
||||||
summary_fields: {
|
|
||||||
organization: {
|
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Default',
|
name: 'Inventory',
|
||||||
},
|
summary_fields: {
|
||||||
user_capabilities: {
|
organization: {
|
||||||
edit: false,
|
id: 1,
|
||||||
copy: true,
|
name: 'Default',
|
||||||
},
|
},
|
||||||
},
|
user_capabilities: {
|
||||||
}}
|
edit: false,
|
||||||
detailUrl="/inventories/inventory/1"
|
copy: true,
|
||||||
isSelected
|
},
|
||||||
onSelect={() => {}}
|
},
|
||||||
/>
|
}}
|
||||||
|
detailUrl="/inventories/inventory/1"
|
||||||
|
isSelected
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
);
|
);
|
||||||
await act(async () =>
|
await act(async () =>
|
||||||
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
||||||
@@ -191,25 +217,29 @@ describe('<InventoryListItem />', () => {
|
|||||||
|
|
||||||
test('should not render copy button', async () => {
|
test('should not render copy button', async () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<InventoryListItem
|
<table>
|
||||||
inventory={{
|
<tbody>
|
||||||
id: 1,
|
<InventoryListItem
|
||||||
name: 'Inventory',
|
inventory={{
|
||||||
summary_fields: {
|
|
||||||
organization: {
|
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Default',
|
name: 'Inventory',
|
||||||
},
|
summary_fields: {
|
||||||
user_capabilities: {
|
organization: {
|
||||||
edit: false,
|
id: 1,
|
||||||
copy: false,
|
name: 'Default',
|
||||||
},
|
},
|
||||||
},
|
user_capabilities: {
|
||||||
}}
|
edit: false,
|
||||||
detailUrl="/inventories/inventory/1"
|
copy: false,
|
||||||
isSelected
|
},
|
||||||
onSelect={() => {}}
|
},
|
||||||
/>
|
}}
|
||||||
|
detailUrl="/inventories/inventory/1"
|
||||||
|
isSelected
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
);
|
);
|
||||||
expect(wrapper.find('CopyButton').length).toBe(0);
|
expect(wrapper.find('CopyButton').length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,7 +47,13 @@ describe('<SmartInventoryEdit />', () => {
|
|||||||
data: { actions: { POST: true } },
|
data: { actions: { POST: true } },
|
||||||
});
|
});
|
||||||
InventoriesAPI.readInstanceGroups.mockResolvedValue({
|
InventoriesAPI.readInstanceGroups.mockResolvedValue({
|
||||||
data: { count: 0, results: [{ id: 10 }, { id: 20 }] },
|
data: {
|
||||||
|
count: 0,
|
||||||
|
results: [
|
||||||
|
{ id: 10, name: 'instance-group-10' },
|
||||||
|
{ id: 20, name: 'instance-group-20' },
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
history = createMemoryHistory({
|
history = createMemoryHistory({
|
||||||
initialEntries: [`/inventories/smart_inventory/${mockSmartInv.id}/edit`],
|
initialEntries: [`/inventories/smart_inventory/${mockSmartInv.id}/edit`],
|
||||||
@@ -85,7 +91,10 @@ describe('<SmartInventoryEdit />', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper.find('SmartInventoryForm').invoke('onSubmit')({
|
wrapper.find('SmartInventoryForm').invoke('onSubmit')({
|
||||||
...mockSmartInv,
|
...mockSmartInv,
|
||||||
instance_groups: [{ id: 10 }, { id: 30 }],
|
instance_groups: [
|
||||||
|
{ id: 10, name: 'instance-group-10' },
|
||||||
|
{ id: 30, name: 'instance-group-30' },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(InventoriesAPI.update).toHaveBeenCalledTimes(1);
|
expect(InventoriesAPI.update).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ describe('<InventoryForm />', () => {
|
|||||||
expect(wrapper.find('VariablesField[label="Variables"]').length).toBe(1);
|
expect(wrapper.find('VariablesField[label="Variables"]').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should update form values', () => {
|
test('should update form values', async () => {
|
||||||
act(() => {
|
await act(async () => {
|
||||||
wrapper.find('OrganizationLookup').invoke('onBlur')();
|
wrapper.find('OrganizationLookup').invoke('onBlur')();
|
||||||
wrapper.find('OrganizationLookup').invoke('onChange')({
|
wrapper.find('OrganizationLookup').invoke('onChange')({
|
||||||
id: 3,
|
id: 3,
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import 'styled-components/macro';
|
import 'styled-components/macro';
|
||||||
import React, { useState, useContext, useEffect } from 'react';
|
import React, { useState, useContext, useEffect } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { func, bool, arrayOf, object } from 'prop-types';
|
import { func, bool, arrayOf } from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Button, Radio, DropdownItem } from '@patternfly/react-core';
|
import { Button, Radio, DropdownItem } from '@patternfly/react-core';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { KebabifiedContext } from '../../../contexts/Kebabified';
|
import { KebabifiedContext } from '../../../contexts/Kebabified';
|
||||||
import { GroupsAPI, InventoriesAPI } from '../../../api';
|
import { GroupsAPI, InventoriesAPI } from '../../../api';
|
||||||
|
import { Group } from '../../../types';
|
||||||
import ErrorDetail from '../../../components/ErrorDetail';
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
import AlertModal from '../../../components/AlertModal';
|
import AlertModal from '../../../components/AlertModal';
|
||||||
|
|
||||||
@@ -158,7 +159,7 @@ const InventoryGroupsDeleteModal = ({
|
|||||||
|
|
||||||
InventoryGroupsDeleteModal.propTypes = {
|
InventoryGroupsDeleteModal.propTypes = {
|
||||||
onAfterDelete: func.isRequired,
|
onAfterDelete: func.isRequired,
|
||||||
groups: arrayOf(object),
|
groups: arrayOf(Group),
|
||||||
isDisabled: bool.isRequired,
|
isDisabled: bool.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import React, { useEffect, useCallback } from 'react';
|
|||||||
import { Formik, useField, useFormikContext } from 'formik';
|
import { Formik, useField, useFormikContext } from 'formik';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { func, shape, object, arrayOf } from 'prop-types';
|
import { func, shape, arrayOf } from 'prop-types';
|
||||||
import { Form } from '@patternfly/react-core';
|
import { Form } from '@patternfly/react-core';
|
||||||
|
import { InstanceGroup } from '../../../types';
|
||||||
import { VariablesField } from '../../../components/CodeMirrorInput';
|
import { VariablesField } from '../../../components/CodeMirrorInput';
|
||||||
import ContentError from '../../../components/ContentError';
|
import ContentError from '../../../components/ContentError';
|
||||||
import ContentLoading from '../../../components/ContentLoading';
|
import ContentLoading from '../../../components/ContentLoading';
|
||||||
@@ -168,7 +169,7 @@ function SmartInventoryForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
SmartInventoryForm.propTypes = {
|
SmartInventoryForm.propTypes = {
|
||||||
instanceGroups: arrayOf(object),
|
instanceGroups: arrayOf(InstanceGroup),
|
||||||
inventory: shape({}),
|
inventory: shape({}),
|
||||||
onCancel: func.isRequired,
|
onCancel: func.isRequired,
|
||||||
onSubmit: func.isRequired,
|
onSubmit: func.isRequired,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const mockFormValues = {
|
|||||||
organization: { id: 1, name: 'mock organization' },
|
organization: { id: 1, name: 'mock organization' },
|
||||||
host_filter:
|
host_filter:
|
||||||
'name__icontains=mock and name__icontains=foo and groups__name__icontains=mock group',
|
'name__icontains=mock and name__icontains=foo and groups__name__icontains=mock group',
|
||||||
instance_groups: [{ id: 123 }],
|
instance_groups: [{ id: 123, name: 'mock instance group' }],
|
||||||
variables: '---',
|
variables: '---',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
|||||||
import AlertModal from '../../../components/AlertModal';
|
import AlertModal from '../../../components/AlertModal';
|
||||||
import DataListToolbar from '../../../components/DataListToolbar';
|
import DataListToolbar from '../../../components/DataListToolbar';
|
||||||
import ErrorDetail from '../../../components/ErrorDetail';
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
import PaginatedDataList, {
|
import {
|
||||||
ToolbarAddButton,
|
ToolbarAddButton,
|
||||||
ToolbarDeleteButton,
|
ToolbarDeleteButton,
|
||||||
} from '../../../components/PaginatedDataList';
|
} from '../../../components/PaginatedDataList';
|
||||||
|
import PaginatedTable, {
|
||||||
|
HeaderRow,
|
||||||
|
HeaderCell,
|
||||||
|
} from '../../../components/PaginatedTable';
|
||||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||||
import OrganizationListItem from './OrganizationListItem';
|
import OrganizationListItem from './OrganizationListItem';
|
||||||
|
|
||||||
@@ -117,14 +121,13 @@ function OrganizationsList({ i18n }) {
|
|||||||
<>
|
<>
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<Card>
|
<Card>
|
||||||
<PaginatedDataList
|
<PaginatedTable
|
||||||
contentError={contentError}
|
contentError={contentError}
|
||||||
hasContentLoading={hasContentLoading}
|
hasContentLoading={hasContentLoading}
|
||||||
items={organizations}
|
items={organizations}
|
||||||
itemCount={organizationCount}
|
itemCount={organizationCount}
|
||||||
pluralizedItemName={i18n._(t`Organizations`)}
|
pluralizedItemName={i18n._(t`Organizations`)}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
onRowClick={handleSelect}
|
|
||||||
toolbarSearchColumns={[
|
toolbarSearchColumns={[
|
||||||
{
|
{
|
||||||
name: i18n._(t`Name`),
|
name: i18n._(t`Name`),
|
||||||
@@ -144,14 +147,16 @@ function OrganizationsList({ i18n }) {
|
|||||||
key: 'modified_by__username__icontains',
|
key: 'modified_by__username__icontains',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
toolbarSortColumns={[
|
|
||||||
{
|
|
||||||
name: i18n._(t`Name`),
|
|
||||||
key: 'name',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
toolbarSearchableKeys={searchableKeys}
|
toolbarSearchableKeys={searchableKeys}
|
||||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||||
|
headerRow={
|
||||||
|
<HeaderRow qsConfig={QS_CONFIG}>
|
||||||
|
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
|
||||||
|
<HeaderCell>{i18n._(t`Members`)}</HeaderCell>
|
||||||
|
<HeaderCell>{i18n._(t`Teams`)}</HeaderCell>
|
||||||
|
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
|
||||||
|
</HeaderRow>
|
||||||
|
}
|
||||||
renderToolbar={props => (
|
renderToolbar={props => (
|
||||||
<DataListToolbar
|
<DataListToolbar
|
||||||
{...props}
|
{...props}
|
||||||
@@ -172,10 +177,11 @@ function OrganizationsList({ i18n }) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderItem={o => (
|
renderRow={(o, index) => (
|
||||||
<OrganizationListItem
|
<OrganizationListItem
|
||||||
key={o.id}
|
key={o.id}
|
||||||
organization={o}
|
organization={o}
|
||||||
|
rowIndex={index}
|
||||||
detailUrl={`${match.url}/${o.id}`}
|
detailUrl={`${match.url}/${o.id}`}
|
||||||
isSelected={selected.some(row => row.id === o.id)}
|
isSelected={selected.some(row => row.id === o.id)}
|
||||||
onSelect={() => handleSelect(o)}
|
onSelect={() => handleSelect(o)}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ describe('<OrganizationsList />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Item appears selected after selecting it', async () => {
|
test('Item appears selected after selecting it', async () => {
|
||||||
const itemCheckboxInput = 'input#select-organization-1';
|
const itemCheckboxInput = 'tr#org-row-1 input[type="checkbox"]';
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<OrganizationsList />);
|
wrapper = mountWithContexts(<OrganizationsList />);
|
||||||
});
|
});
|
||||||
@@ -115,7 +115,6 @@ describe('<OrganizationsList />', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper
|
wrapper
|
||||||
.find(itemCheckboxInput)
|
.find(itemCheckboxInput)
|
||||||
.closest('DataListCheck')
|
|
||||||
.props()
|
.props()
|
||||||
.onChange();
|
.onChange();
|
||||||
});
|
});
|
||||||
@@ -128,9 +127,9 @@ describe('<OrganizationsList />', () => {
|
|||||||
|
|
||||||
test('All items appear selected after select-all and unselected after unselect-all', async () => {
|
test('All items appear selected after select-all and unselected after unselect-all', async () => {
|
||||||
const itemCheckboxInputs = [
|
const itemCheckboxInputs = [
|
||||||
'input#select-organization-1',
|
'tr#org-row-1 input[type="checkbox"]',
|
||||||
'input#select-organization-2',
|
'tr#org-row-2 input[type="checkbox"]',
|
||||||
'input#select-organization-3',
|
'tr#org-row-3 input[type="checkbox"]',
|
||||||
];
|
];
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<OrganizationsList />);
|
wrapper = mountWithContexts(<OrganizationsList />);
|
||||||
@@ -227,7 +226,7 @@ describe('<OrganizationsList />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Error dialog shown for failed deletion', async () => {
|
test('Error dialog shown for failed deletion', async () => {
|
||||||
const itemCheckboxInput = 'input#select-organization-1';
|
const itemCheckboxInput = 'tr#org-row-1 input[type="checkbox"]';
|
||||||
OrganizationsAPI.destroy.mockRejectedValue(
|
OrganizationsAPI.destroy.mockRejectedValue(
|
||||||
new Error({
|
new Error({
|
||||||
response: {
|
response: {
|
||||||
@@ -250,7 +249,6 @@ describe('<OrganizationsList />', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper
|
wrapper
|
||||||
.find(itemCheckboxInput)
|
.find(itemCheckboxInput)
|
||||||
.closest('DataListCheck')
|
|
||||||
.props()
|
.props()
|
||||||
.onChange();
|
.onChange();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,105 +2,61 @@ import React from 'react';
|
|||||||
import { string, bool, func } from 'prop-types';
|
import { string, bool, func } from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import {
|
import { Button } from '@patternfly/react-core';
|
||||||
Badge as PFBadge,
|
import { Tr, Td } from '@patternfly/react-table';
|
||||||
Button,
|
|
||||||
DataListAction as _DataListAction,
|
|
||||||
DataListCheck,
|
|
||||||
DataListItem,
|
|
||||||
DataListItemCells,
|
|
||||||
DataListItemRow,
|
|
||||||
Tooltip,
|
|
||||||
} from '@patternfly/react-core';
|
|
||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import styled from 'styled-components';
|
|
||||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||||
import DataListCell from '../../../components/DataListCell';
|
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
|
||||||
|
|
||||||
import { Organization } from '../../../types';
|
import { Organization } from '../../../types';
|
||||||
|
|
||||||
const Badge = styled(PFBadge)`
|
|
||||||
margin-left: 8px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ListGroup = styled.span`
|
|
||||||
margin-left: 24px;
|
|
||||||
|
|
||||||
&:first-of-type {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const DataListAction = styled(_DataListAction)`
|
|
||||||
align-items: center;
|
|
||||||
display: grid;
|
|
||||||
grid-gap: 16px;
|
|
||||||
grid-template-columns: 40px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
function OrganizationListItem({
|
function OrganizationListItem({
|
||||||
organization,
|
organization,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
rowIndex,
|
||||||
detailUrl,
|
detailUrl,
|
||||||
i18n,
|
i18n,
|
||||||
}) {
|
}) {
|
||||||
const labelId = `check-action-${organization.id}`;
|
const labelId = `check-action-${organization.id}`;
|
||||||
return (
|
return (
|
||||||
<DataListItem
|
<Tr id={`org-row-${organization.id}`}>
|
||||||
key={organization.id}
|
<Td
|
||||||
aria-labelledby={labelId}
|
select={{
|
||||||
id={`${organization.id}`}
|
rowIndex,
|
||||||
>
|
isSelected,
|
||||||
<DataListItemRow>
|
onSelect,
|
||||||
<DataListCheck
|
disable: false,
|
||||||
id={`select-organization-${organization.id}`}
|
}}
|
||||||
checked={isSelected}
|
dataLabel={i18n._(t`Selected`)}
|
||||||
onChange={onSelect}
|
/>
|
||||||
aria-labelledby={labelId}
|
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||||
/>
|
<Link to={`${detailUrl}`}>
|
||||||
<DataListItemCells
|
<b>{organization.name}</b>
|
||||||
dataListCells={[
|
</Link>
|
||||||
<DataListCell key="name" id={labelId}>
|
</Td>
|
||||||
<Link to={`${detailUrl}`}>
|
<Td dataLabel={i18n._(t`Members`)}>
|
||||||
<b>{organization.name}</b>
|
{organization.summary_fields.related_field_counts.users}
|
||||||
</Link>
|
</Td>
|
||||||
</DataListCell>,
|
<Td dataLabel={i18n._(t`Teams`)}>
|
||||||
<DataListCell key="related-field-counts">
|
{organization.summary_fields.related_field_counts.teams}
|
||||||
<ListGroup>
|
</Td>
|
||||||
{i18n._(t`Members`)}
|
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||||
<Badge isRead>
|
<ActionItem
|
||||||
{organization.summary_fields.related_field_counts.users}
|
visible={organization.summary_fields.user_capabilities.edit}
|
||||||
</Badge>
|
tooltip={i18n._(t`Edit Organization`)}
|
||||||
</ListGroup>
|
>
|
||||||
<ListGroup>
|
<Button
|
||||||
{i18n._(t`Teams`)}
|
aria-label={i18n._(t`Edit Organization`)}
|
||||||
<Badge isRead>
|
variant="plain"
|
||||||
{organization.summary_fields.related_field_counts.teams}
|
component={Link}
|
||||||
</Badge>
|
to={`/organizations/${organization.id}/edit`}
|
||||||
</ListGroup>
|
>
|
||||||
</DataListCell>,
|
<PencilAltIcon />
|
||||||
]}
|
</Button>
|
||||||
/>
|
</ActionItem>
|
||||||
<DataListAction aria-label="actions" aria-labelledby={labelId}>
|
</ActionsTd>
|
||||||
{organization.summary_fields.user_capabilities.edit ? (
|
</Tr>
|
||||||
<Tooltip content={i18n._(t`Edit Organization`)} position="top">
|
|
||||||
<Button
|
|
||||||
aria-label={i18n._(t`Edit Organization`)}
|
|
||||||
variant="plain"
|
|
||||||
component={Link}
|
|
||||||
to={`/organizations/${organization.id}/edit`}
|
|
||||||
>
|
|
||||||
<PencilAltIcon />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)}
|
|
||||||
</DataListAction>
|
|
||||||
</DataListItemRow>
|
|
||||||
</DataListItem>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,77 +11,91 @@ describe('<OrganizationListItem />', () => {
|
|||||||
mountWithContexts(
|
mountWithContexts(
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<MemoryRouter initialEntries={['/organizations']} initialIndex={0}>
|
<MemoryRouter initialEntries={['/organizations']} initialIndex={0}>
|
||||||
<OrganizationListItem
|
<table>
|
||||||
organization={{
|
<tbody>
|
||||||
id: 1,
|
<OrganizationListItem
|
||||||
name: 'Org',
|
organization={{
|
||||||
summary_fields: {
|
id: 1,
|
||||||
related_field_counts: {
|
name: 'Org',
|
||||||
users: 1,
|
summary_fields: {
|
||||||
teams: 1,
|
related_field_counts: {
|
||||||
},
|
users: 1,
|
||||||
user_capabilities: {
|
teams: 1,
|
||||||
edit: true,
|
},
|
||||||
},
|
user_capabilities: {
|
||||||
},
|
edit: true,
|
||||||
}}
|
},
|
||||||
detailUrl="/organization/1"
|
},
|
||||||
isSelected
|
}}
|
||||||
onSelect={() => {}}
|
detailUrl="/organization/1"
|
||||||
/>
|
isSelected
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('edit button shown to users with edit capabilities', () => {
|
test('edit button shown to users with edit capabilities', () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<MemoryRouter initialEntries={['/organizations']} initialIndex={0}>
|
<MemoryRouter initialEntries={['/organizations']} initialIndex={0}>
|
||||||
<OrganizationListItem
|
<table>
|
||||||
organization={{
|
<tbody>
|
||||||
id: 1,
|
<OrganizationListItem
|
||||||
name: 'Org',
|
organization={{
|
||||||
summary_fields: {
|
id: 1,
|
||||||
related_field_counts: {
|
name: 'Org',
|
||||||
users: 1,
|
summary_fields: {
|
||||||
teams: 1,
|
related_field_counts: {
|
||||||
},
|
users: 1,
|
||||||
user_capabilities: {
|
teams: 1,
|
||||||
edit: true,
|
},
|
||||||
},
|
user_capabilities: {
|
||||||
},
|
edit: true,
|
||||||
}}
|
},
|
||||||
detailUrl="/organization/1"
|
},
|
||||||
isSelected
|
}}
|
||||||
onSelect={() => {}}
|
detailUrl="/organization/1"
|
||||||
/>
|
isSelected
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
|
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('edit button hidden from users without edit capabilities', () => {
|
test('edit button hidden from users without edit capabilities', () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<MemoryRouter initialEntries={['/organizations']} initialIndex={0}>
|
<MemoryRouter initialEntries={['/organizations']} initialIndex={0}>
|
||||||
<OrganizationListItem
|
<table>
|
||||||
organization={{
|
<tbody>
|
||||||
id: 1,
|
<OrganizationListItem
|
||||||
name: 'Org',
|
organization={{
|
||||||
summary_fields: {
|
id: 1,
|
||||||
related_field_counts: {
|
name: 'Org',
|
||||||
users: 1,
|
summary_fields: {
|
||||||
teams: 1,
|
related_field_counts: {
|
||||||
},
|
users: 1,
|
||||||
user_capabilities: {
|
teams: 1,
|
||||||
edit: false,
|
},
|
||||||
},
|
user_capabilities: {
|
||||||
},
|
edit: false,
|
||||||
}}
|
},
|
||||||
detailUrl="/organization/1"
|
},
|
||||||
isSelected
|
}}
|
||||||
onSelect={() => {}}
|
detailUrl="/organization/1"
|
||||||
/>
|
isSelected
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ describe('<TeamForm />', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
await act(async () => {
|
||||||
wrapper.find('input#team-name').simulate('change', {
|
wrapper.find('input#team-name').simulate('change', {
|
||||||
target: { value: 'new foo', name: 'name' },
|
target: { value: 'new foo', name: 'name' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export function mountWithContexts(node, options = {}) {
|
|||||||
const context = applyDefaultContexts(options.context);
|
const context = applyDefaultContexts(options.context);
|
||||||
const childContextTypes = {
|
const childContextTypes = {
|
||||||
linguiPublisher: shape({
|
linguiPublisher: shape({
|
||||||
i18n: object.isRequired,
|
i18n: object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
config: shape({
|
config: shape({
|
||||||
ansible_version: string,
|
ansible_version: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user