mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 01:57:35 -03: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:
commit
5632f72bb1
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/react-core": "4.84.3",
|
||||
"@patternfly/react-icons": "4.7.22",
|
||||
"@patternfly/react-table": "^4.19.15",
|
||||
"ansi-to-html": "^0.6.11",
|
||||
"axios": "^0.18.1",
|
||||
"codemirror": "^5.47.0",
|
||||
|
||||
@ -93,9 +93,11 @@ function DataListToolbar({
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
<Sort qsConfig={qsConfig} columns={sortColumns} onSort={onSort} />
|
||||
</ToolbarItem>
|
||||
{sortColumns && (
|
||||
<ToolbarItem>
|
||||
<Sort qsConfig={qsConfig} columns={sortColumns} onSort={onSort} />
|
||||
</ToolbarItem>
|
||||
)}
|
||||
</ToolbarToggleGroup>
|
||||
{showExpandCollapse && (
|
||||
<ToolbarGroup>
|
||||
@ -157,7 +159,7 @@ DataListToolbar.propTypes = {
|
||||
searchColumns: SearchColumns.isRequired,
|
||||
searchableKeys: PropTypes.arrayOf(PropTypes.string),
|
||||
relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
|
||||
sortColumns: SortColumns.isRequired,
|
||||
sortColumns: SortColumns,
|
||||
showSelectAll: PropTypes.bool,
|
||||
isAllSelected: PropTypes.bool,
|
||||
isCompact: PropTypes.bool,
|
||||
@ -174,6 +176,7 @@ DataListToolbar.defaultProps = {
|
||||
itemCount: 0,
|
||||
searchableKeys: [],
|
||||
relatedSearchableKeys: [],
|
||||
sortColumns: null,
|
||||
clearAllFilters: null,
|
||||
showSelectAll: false,
|
||||
isAllSelected: false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { t } from '@lingui/macro';
|
||||
import { Button, Tooltip, DropdownItem } from '@patternfly/react-core';
|
||||
@ -149,7 +149,20 @@ DisassociateButton.defaultProps = {
|
||||
};
|
||||
|
||||
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,
|
||||
modalTitle: string,
|
||||
onDisassociate: func.isRequired,
|
||||
|
||||
@ -147,13 +147,14 @@ ListHeader.propTypes = {
|
||||
searchColumns: SearchColumns.isRequired,
|
||||
searchableKeys: PropTypes.arrayOf(PropTypes.string),
|
||||
relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
|
||||
sortColumns: SortColumns.isRequired,
|
||||
sortColumns: SortColumns,
|
||||
renderToolbar: PropTypes.func,
|
||||
};
|
||||
|
||||
ListHeader.defaultProps = {
|
||||
renderToolbar: props => <DataListToolbar {...props} />,
|
||||
searchableKeys: [],
|
||||
sortColumns: null,
|
||||
relatedSearchableKeys: [],
|
||||
};
|
||||
|
||||
|
||||
@ -279,8 +279,8 @@ function HostFilterLookup({
|
||||
numChips={5}
|
||||
totalChips={chips[key]?.chips?.length || 0}
|
||||
>
|
||||
{chips[key]?.chips?.map((chip, index) => (
|
||||
<Chip key={index} isReadOnly>
|
||||
{chips[key]?.chips?.map(chip => (
|
||||
<Chip key={chip.key} isReadOnly>
|
||||
{chip.node}
|
||||
</Chip>
|
||||
))}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
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 { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { FormGroup } from '@patternfly/react-core';
|
||||
import { InstanceGroupsAPI } from '../../api';
|
||||
import { InstanceGroup } from '../../types';
|
||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||
import Popover from '../Popover';
|
||||
import OptionsList from '../OptionsList';
|
||||
@ -120,7 +121,7 @@ function InstanceGroupsLookup(props) {
|
||||
}
|
||||
|
||||
InstanceGroupsLookup.propTypes = {
|
||||
value: arrayOf(object).isRequired,
|
||||
value: arrayOf(InstanceGroup).isRequired,
|
||||
tooltip: string,
|
||||
onChange: func.isRequired,
|
||||
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 { shape, string, number, arrayOf, node, oneOfType } from 'prop-types';
|
||||
import { Tab, Tabs, TabTitleText } from '@patternfly/react-core';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
function RoutedTabs(props) {
|
||||
const { tabsArray } = props;
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
|
||||
const getActiveTabId = () => {
|
||||
const match = tabsArray.find(tab => tab.link === history.location.pathname);
|
||||
const match = tabsArray.find(tab => tab.link === location.pathname);
|
||||
if (match) {
|
||||
return match.id;
|
||||
}
|
||||
const subpathMatch = tabsArray.find(tab =>
|
||||
history.location.pathname.startsWith(tab.link)
|
||||
location.pathname.startsWith(tab.link)
|
||||
);
|
||||
if (subpathMatch) {
|
||||
return subpathMatch.id;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { arrayOf, object } from 'prop-types';
|
||||
import { arrayOf } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { Link as _Link } from 'react-router-dom';
|
||||
import { Tooltip } from '@patternfly/react-core';
|
||||
@ -8,6 +8,7 @@ import { t } from '@lingui/macro';
|
||||
import StatusIcon from '../StatusIcon';
|
||||
import { formatDateString } from '../../util/dates';
|
||||
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
|
||||
import { Job } from '../../types';
|
||||
|
||||
/* eslint-disable react/jsx-pascal-case */
|
||||
const Link = styled(props => <_Link {...props} />)`
|
||||
@ -52,7 +53,7 @@ const Sparkline = ({ i18n, jobs }) => {
|
||||
};
|
||||
|
||||
Sparkline.propTypes = {
|
||||
jobs: arrayOf(object),
|
||||
jobs: arrayOf(Job),
|
||||
};
|
||||
Sparkline.defaultProps = {
|
||||
jobs: [],
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
SyncAltIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ClockIcon,
|
||||
MinusCircleIcon,
|
||||
} from '@patternfly/react-icons';
|
||||
import styled, { keyframes } from 'styled-components';
|
||||
|
||||
@ -32,6 +33,7 @@ const colors = {
|
||||
running: 'blue',
|
||||
pending: 'blue',
|
||||
waiting: 'grey',
|
||||
disabled: 'grey',
|
||||
canceled: 'orange',
|
||||
};
|
||||
const icons = {
|
||||
@ -42,6 +44,7 @@ const icons = {
|
||||
running: RunningIcon,
|
||||
pending: ClockIcon,
|
||||
waiting: ClockIcon,
|
||||
disabled: MinusCircleIcon,
|
||||
canceled: ExclamationTriangleIcon,
|
||||
};
|
||||
|
||||
@ -66,6 +69,7 @@ StatusLabel.propTypes = {
|
||||
'running',
|
||||
'pending',
|
||||
'waiting',
|
||||
'disabled',
|
||||
'canceled',
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
@ -8,9 +8,11 @@ import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import DatalistToolbar from '../../../components/DataListToolbar';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import PaginatedDataList, {
|
||||
ToolbarDeleteButton,
|
||||
} from '../../../components/PaginatedDataList';
|
||||
import { ToolbarDeleteButton } from '../../../components/PaginatedDataList';
|
||||
import PaginatedTable, {
|
||||
HeaderRow,
|
||||
HeaderCell,
|
||||
} from '../../../components/PaginatedTable';
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import useWsInventories from './useWsInventories';
|
||||
import AddDropDownButton from '../../../components/AddDropDownButton';
|
||||
@ -149,17 +151,17 @@ function InventoryList({ i18n }) {
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedDataList
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={hasContentLoading}
|
||||
items={inventories}
|
||||
itemCount={itemCount}
|
||||
pluralizedItemName={i18n._(t`Inventories`)}
|
||||
qsConfig={QS_CONFIG}
|
||||
onRowClick={handleSelect}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
@ -187,6 +189,18 @@ function InventoryList({ i18n }) {
|
||||
]}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
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 => (
|
||||
<DatalistToolbar
|
||||
{...props}
|
||||
@ -209,11 +223,12 @@ function InventoryList({ i18n }) {
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
renderItem={inventory => (
|
||||
renderRow={(inventory, index) => (
|
||||
<InventoryListItem
|
||||
key={inventory.id}
|
||||
value={inventory.name}
|
||||
inventory={inventory}
|
||||
rowIndex={index}
|
||||
fetchInventories={fetchInventories}
|
||||
detailUrl={
|
||||
inventory.kind === 'smart'
|
||||
|
||||
@ -1,50 +1,21 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { string, bool, func } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import {
|
||||
Button,
|
||||
DataListAction as _DataListAction,
|
||||
DataListCheck,
|
||||
DataListItem,
|
||||
DataListItemCells,
|
||||
DataListItemRow,
|
||||
Label,
|
||||
Tooltip,
|
||||
Badge as PFBadge,
|
||||
} from '@patternfly/react-core';
|
||||
import { Button, Label } from '@patternfly/react-core';
|
||||
import { Tr, Td } from '@patternfly/react-table';
|
||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { timeOfDay } from '../../../util/dates';
|
||||
import { InventoriesAPI } from '../../../api';
|
||||
import { Inventory } from '../../../types';
|
||||
import DataListCell from '../../../components/DataListCell';
|
||||
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
|
||||
import CopyButton from '../../../components/CopyButton';
|
||||
import SyncStatusIndicator from '../../../components/SyncStatusIndicator';
|
||||
|
||||
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;
|
||||
`;
|
||||
import StatusLabel from '../../../components/StatusLabel';
|
||||
|
||||
function InventoryListItem({
|
||||
inventory,
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
detailUrl,
|
||||
@ -85,108 +56,84 @@ function InventoryListItem({
|
||||
}
|
||||
|
||||
return (
|
||||
<DataListItem
|
||||
key={inventory.id}
|
||||
aria-labelledby={labelId}
|
||||
id={`${inventory.id}`}
|
||||
>
|
||||
<DataListItemRow>
|
||||
<DataListCheck
|
||||
id={`select-inventory-${inventory.id}`}
|
||||
isDisabled={inventory.pending_deletion}
|
||||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key="sync-status" isIcon>
|
||||
{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>
|
||||
<Tr id={inventory.id} aria-labelledby={labelId}>
|
||||
<Td
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}}
|
||||
dataLabel={i18n._(t`Selected`)}
|
||||
/>
|
||||
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||
{inventory.pending_deletion ? (
|
||||
<b>{inventory.name}</b>
|
||||
) : (
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{inventory.name}</b>
|
||||
</Link>
|
||||
)}
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
</Td>
|
||||
<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);
|
||||
|
||||
@ -9,145 +9,167 @@ jest.mock('../../../api/models/Inventories');
|
||||
describe('<InventoryListItem />', () => {
|
||||
test('initially renders succesfully', () => {
|
||||
mountWithContexts(
|
||||
<InventoryListItem
|
||||
inventory={{
|
||||
id: 1,
|
||||
name: 'Inventory',
|
||||
summary_fields: {
|
||||
organization: {
|
||||
<table>
|
||||
<tbody>
|
||||
<InventoryListItem
|
||||
inventory={{
|
||||
id: 1,
|
||||
name: 'Default',
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/inventories/inventory/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
name: 'Inventory',
|
||||
summary_fields: {
|
||||
organization: {
|
||||
id: 1,
|
||||
name: 'Default',
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/inventories/inventory/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
|
||||
test('should render prompt list item data', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<InventoryListItem
|
||||
inventory={{
|
||||
id: 1,
|
||||
name: 'Inventory',
|
||||
kind: '',
|
||||
summary_fields: {
|
||||
organization: {
|
||||
<table>
|
||||
<tbody>
|
||||
<InventoryListItem
|
||||
inventory={{
|
||||
id: 1,
|
||||
name: 'Default',
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/inventories/inventory/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
name: 'Inventory',
|
||||
kind: '',
|
||||
summary_fields: {
|
||||
organization: {
|
||||
id: 1,
|
||||
name: 'Default',
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/inventories/inventory/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('SyncStatusIndicator').length).toBe(1);
|
||||
expect(wrapper.find('StatusLabel').length).toBe(1);
|
||||
expect(
|
||||
wrapper
|
||||
.find('DataListCell')
|
||||
.find('Td')
|
||||
.at(1)
|
||||
.text()
|
||||
).toBe('Inventory');
|
||||
expect(
|
||||
wrapper
|
||||
.find('DataListCell')
|
||||
.find('Td')
|
||||
.at(2)
|
||||
.text()
|
||||
).toBe('Disabled');
|
||||
expect(
|
||||
wrapper
|
||||
.find('Td')
|
||||
.at(3)
|
||||
.text()
|
||||
).toBe('Inventory');
|
||||
expect(
|
||||
wrapper
|
||||
.find('DataListCell')
|
||||
.at(3)
|
||||
.text()
|
||||
).toBe('OrganizationDefault');
|
||||
expect(
|
||||
wrapper
|
||||
.find('DataListCell')
|
||||
.find('Td')
|
||||
.at(4)
|
||||
.text()
|
||||
).toBe('GroupsHostsSources');
|
||||
).toBe('Default');
|
||||
});
|
||||
|
||||
test('edit button shown to users with edit capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<InventoryListItem
|
||||
inventory={{
|
||||
id: 1,
|
||||
name: 'Inventory',
|
||||
summary_fields: {
|
||||
organization: {
|
||||
<table>
|
||||
<tbody>
|
||||
<InventoryListItem
|
||||
inventory={{
|
||||
id: 1,
|
||||
name: 'Default',
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/inventories/inventory/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
name: 'Inventory',
|
||||
summary_fields: {
|
||||
organization: {
|
||||
id: 1,
|
||||
name: 'Default',
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/inventories/inventory/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('edit button hidden from users without edit capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<InventoryListItem
|
||||
inventory={{
|
||||
id: 1,
|
||||
name: 'Inventory',
|
||||
summary_fields: {
|
||||
organization: {
|
||||
<table>
|
||||
<tbody>
|
||||
<InventoryListItem
|
||||
inventory={{
|
||||
id: 1,
|
||||
name: 'Default',
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/inventories/inventory/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
name: 'Inventory',
|
||||
summary_fields: {
|
||||
organization: {
|
||||
id: 1,
|
||||
name: 'Default',
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/inventories/inventory/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should call api to copy inventory', async () => {
|
||||
InventoriesAPI.copy.mockResolvedValue();
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<InventoryListItem
|
||||
inventory={{
|
||||
id: 1,
|
||||
name: 'Inventory',
|
||||
summary_fields: {
|
||||
organization: {
|
||||
<table>
|
||||
<tbody>
|
||||
<InventoryListItem
|
||||
inventory={{
|
||||
id: 1,
|
||||
name: 'Default',
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
copy: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/inventories/inventory/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
name: 'Inventory',
|
||||
summary_fields: {
|
||||
organization: {
|
||||
id: 1,
|
||||
name: 'Default',
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
copy: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/inventories/inventory/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
await act(async () =>
|
||||
@ -161,25 +183,29 @@ describe('<InventoryListItem />', () => {
|
||||
InventoriesAPI.copy.mockRejectedValue(new Error());
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<InventoryListItem
|
||||
inventory={{
|
||||
id: 1,
|
||||
name: 'Inventory',
|
||||
summary_fields: {
|
||||
organization: {
|
||||
<table>
|
||||
<tbody>
|
||||
<InventoryListItem
|
||||
inventory={{
|
||||
id: 1,
|
||||
name: 'Default',
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
copy: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/inventories/inventory/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
name: 'Inventory',
|
||||
summary_fields: {
|
||||
organization: {
|
||||
id: 1,
|
||||
name: 'Default',
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
copy: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/inventories/inventory/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
||||
@ -191,25 +217,29 @@ describe('<InventoryListItem />', () => {
|
||||
|
||||
test('should not render copy button', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<InventoryListItem
|
||||
inventory={{
|
||||
id: 1,
|
||||
name: 'Inventory',
|
||||
summary_fields: {
|
||||
organization: {
|
||||
<table>
|
||||
<tbody>
|
||||
<InventoryListItem
|
||||
inventory={{
|
||||
id: 1,
|
||||
name: 'Default',
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
copy: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/inventories/inventory/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
name: 'Inventory',
|
||||
summary_fields: {
|
||||
organization: {
|
||||
id: 1,
|
||||
name: 'Default',
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
copy: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/inventories/inventory/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('CopyButton').length).toBe(0);
|
||||
});
|
||||
|
||||
@ -47,7 +47,13 @@ describe('<SmartInventoryEdit />', () => {
|
||||
data: { actions: { POST: true } },
|
||||
});
|
||||
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({
|
||||
initialEntries: [`/inventories/smart_inventory/${mockSmartInv.id}/edit`],
|
||||
@ -85,7 +91,10 @@ describe('<SmartInventoryEdit />', () => {
|
||||
await act(async () => {
|
||||
wrapper.find('SmartInventoryForm').invoke('onSubmit')({
|
||||
...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);
|
||||
|
||||
@ -96,8 +96,8 @@ describe('<InventoryForm />', () => {
|
||||
expect(wrapper.find('VariablesField[label="Variables"]').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should update form values', () => {
|
||||
act(() => {
|
||||
test('should update form values', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('OrganizationLookup').invoke('onBlur')();
|
||||
wrapper.find('OrganizationLookup').invoke('onChange')({
|
||||
id: 3,
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import 'styled-components/macro';
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
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 { t } from '@lingui/macro';
|
||||
import { Button, Radio, DropdownItem } from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
import { KebabifiedContext } from '../../../contexts/Kebabified';
|
||||
import { GroupsAPI, InventoriesAPI } from '../../../api';
|
||||
import { Group } from '../../../types';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
|
||||
@ -158,7 +159,7 @@ const InventoryGroupsDeleteModal = ({
|
||||
|
||||
InventoryGroupsDeleteModal.propTypes = {
|
||||
onAfterDelete: func.isRequired,
|
||||
groups: arrayOf(object),
|
||||
groups: arrayOf(Group),
|
||||
isDisabled: bool.isRequired,
|
||||
};
|
||||
|
||||
|
||||
@ -2,8 +2,9 @@ import React, { useEffect, useCallback } from 'react';
|
||||
import { Formik, useField, useFormikContext } from 'formik';
|
||||
import { withI18n } from '@lingui/react';
|
||||
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 { InstanceGroup } from '../../../types';
|
||||
import { VariablesField } from '../../../components/CodeMirrorInput';
|
||||
import ContentError from '../../../components/ContentError';
|
||||
import ContentLoading from '../../../components/ContentLoading';
|
||||
@ -168,7 +169,7 @@ function SmartInventoryForm({
|
||||
}
|
||||
|
||||
SmartInventoryForm.propTypes = {
|
||||
instanceGroups: arrayOf(object),
|
||||
instanceGroups: arrayOf(InstanceGroup),
|
||||
inventory: shape({}),
|
||||
onCancel: func.isRequired,
|
||||
onSubmit: func.isRequired,
|
||||
|
||||
@ -27,7 +27,7 @@ const mockFormValues = {
|
||||
organization: { id: 1, name: 'mock organization' },
|
||||
host_filter:
|
||||
'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: '---',
|
||||
};
|
||||
|
||||
|
||||
@ -9,10 +9,14 @@ import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import DataListToolbar from '../../../components/DataListToolbar';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import PaginatedDataList, {
|
||||
import {
|
||||
ToolbarAddButton,
|
||||
ToolbarDeleteButton,
|
||||
} from '../../../components/PaginatedDataList';
|
||||
import PaginatedTable, {
|
||||
HeaderRow,
|
||||
HeaderCell,
|
||||
} from '../../../components/PaginatedTable';
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import OrganizationListItem from './OrganizationListItem';
|
||||
|
||||
@ -117,14 +121,13 @@ function OrganizationsList({ i18n }) {
|
||||
<>
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedDataList
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={hasContentLoading}
|
||||
items={organizations}
|
||||
itemCount={organizationCount}
|
||||
pluralizedItemName={i18n._(t`Organizations`)}
|
||||
qsConfig={QS_CONFIG}
|
||||
onRowClick={handleSelect}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
@ -144,14 +147,16 @@ function OrganizationsList({ i18n }) {
|
||||
key: 'modified_by__username__icontains',
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
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 => (
|
||||
<DataListToolbar
|
||||
{...props}
|
||||
@ -172,10 +177,11 @@ function OrganizationsList({ i18n }) {
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
renderItem={o => (
|
||||
renderRow={(o, index) => (
|
||||
<OrganizationListItem
|
||||
key={o.id}
|
||||
organization={o}
|
||||
rowIndex={index}
|
||||
detailUrl={`${match.url}/${o.id}`}
|
||||
isSelected={selected.some(row => row.id === o.id)}
|
||||
onSelect={() => handleSelect(o)}
|
||||
|
||||
@ -103,7 +103,7 @@ describe('<OrganizationsList />', () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
wrapper = mountWithContexts(<OrganizationsList />);
|
||||
});
|
||||
@ -115,7 +115,6 @@ describe('<OrganizationsList />', () => {
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find(itemCheckboxInput)
|
||||
.closest('DataListCheck')
|
||||
.props()
|
||||
.onChange();
|
||||
});
|
||||
@ -128,9 +127,9 @@ describe('<OrganizationsList />', () => {
|
||||
|
||||
test('All items appear selected after select-all and unselected after unselect-all', async () => {
|
||||
const itemCheckboxInputs = [
|
||||
'input#select-organization-1',
|
||||
'input#select-organization-2',
|
||||
'input#select-organization-3',
|
||||
'tr#org-row-1 input[type="checkbox"]',
|
||||
'tr#org-row-2 input[type="checkbox"]',
|
||||
'tr#org-row-3 input[type="checkbox"]',
|
||||
];
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<OrganizationsList />);
|
||||
@ -227,7 +226,7 @@ describe('<OrganizationsList />', () => {
|
||||
});
|
||||
|
||||
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(
|
||||
new Error({
|
||||
response: {
|
||||
@ -250,7 +249,6 @@ describe('<OrganizationsList />', () => {
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find(itemCheckboxInput)
|
||||
.closest('DataListCheck')
|
||||
.props()
|
||||
.onChange();
|
||||
});
|
||||
|
||||
@ -2,105 +2,61 @@ import React from 'react';
|
||||
import { string, bool, func } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Badge as PFBadge,
|
||||
Button,
|
||||
DataListAction as _DataListAction,
|
||||
DataListCheck,
|
||||
DataListItem,
|
||||
DataListItemCells,
|
||||
DataListItemRow,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { Tr, Td } from '@patternfly/react-table';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||
import DataListCell from '../../../components/DataListCell';
|
||||
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
|
||||
|
||||
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({
|
||||
organization,
|
||||
isSelected,
|
||||
onSelect,
|
||||
rowIndex,
|
||||
detailUrl,
|
||||
i18n,
|
||||
}) {
|
||||
const labelId = `check-action-${organization.id}`;
|
||||
return (
|
||||
<DataListItem
|
||||
key={organization.id}
|
||||
aria-labelledby={labelId}
|
||||
id={`${organization.id}`}
|
||||
>
|
||||
<DataListItemRow>
|
||||
<DataListCheck
|
||||
id={`select-organization-${organization.id}`}
|
||||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key="name" id={labelId}>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{organization.name}</b>
|
||||
</Link>
|
||||
</DataListCell>,
|
||||
<DataListCell key="related-field-counts">
|
||||
<ListGroup>
|
||||
{i18n._(t`Members`)}
|
||||
<Badge isRead>
|
||||
{organization.summary_fields.related_field_counts.users}
|
||||
</Badge>
|
||||
</ListGroup>
|
||||
<ListGroup>
|
||||
{i18n._(t`Teams`)}
|
||||
<Badge isRead>
|
||||
{organization.summary_fields.related_field_counts.teams}
|
||||
</Badge>
|
||||
</ListGroup>
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
<DataListAction aria-label="actions" aria-labelledby={labelId}>
|
||||
{organization.summary_fields.user_capabilities.edit ? (
|
||||
<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>
|
||||
<Tr id={`org-row-${organization.id}`}>
|
||||
<Td
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
disable: false,
|
||||
}}
|
||||
dataLabel={i18n._(t`Selected`)}
|
||||
/>
|
||||
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{organization.name}</b>
|
||||
</Link>
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Members`)}>
|
||||
{organization.summary_fields.related_field_counts.users}
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Teams`)}>
|
||||
{organization.summary_fields.related_field_counts.teams}
|
||||
</Td>
|
||||
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||
<ActionItem
|
||||
visible={organization.summary_fields.user_capabilities.edit}
|
||||
tooltip={i18n._(t`Edit Organization`)}
|
||||
>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit Organization`)}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/organizations/${organization.id}/edit`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -11,77 +11,91 @@ describe('<OrganizationListItem />', () => {
|
||||
mountWithContexts(
|
||||
<I18nProvider>
|
||||
<MemoryRouter initialEntries={['/organizations']} initialIndex={0}>
|
||||
<OrganizationListItem
|
||||
organization={{
|
||||
id: 1,
|
||||
name: 'Org',
|
||||
summary_fields: {
|
||||
related_field_counts: {
|
||||
users: 1,
|
||||
teams: 1,
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/organization/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<OrganizationListItem
|
||||
organization={{
|
||||
id: 1,
|
||||
name: 'Org',
|
||||
summary_fields: {
|
||||
related_field_counts: {
|
||||
users: 1,
|
||||
teams: 1,
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/organization/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</MemoryRouter>
|
||||
</I18nProvider>
|
||||
);
|
||||
});
|
||||
|
||||
test('edit button shown to users with edit capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<I18nProvider>
|
||||
<MemoryRouter initialEntries={['/organizations']} initialIndex={0}>
|
||||
<OrganizationListItem
|
||||
organization={{
|
||||
id: 1,
|
||||
name: 'Org',
|
||||
summary_fields: {
|
||||
related_field_counts: {
|
||||
users: 1,
|
||||
teams: 1,
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/organization/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<OrganizationListItem
|
||||
organization={{
|
||||
id: 1,
|
||||
name: 'Org',
|
||||
summary_fields: {
|
||||
related_field_counts: {
|
||||
users: 1,
|
||||
teams: 1,
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/organization/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</MemoryRouter>
|
||||
</I18nProvider>
|
||||
);
|
||||
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('edit button hidden from users without edit capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<I18nProvider>
|
||||
<MemoryRouter initialEntries={['/organizations']} initialIndex={0}>
|
||||
<OrganizationListItem
|
||||
organization={{
|
||||
id: 1,
|
||||
name: 'Org',
|
||||
summary_fields: {
|
||||
related_field_counts: {
|
||||
users: 1,
|
||||
teams: 1,
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/organization/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<OrganizationListItem
|
||||
organization={{
|
||||
id: 1,
|
||||
name: 'Org',
|
||||
summary_fields: {
|
||||
related_field_counts: {
|
||||
users: 1,
|
||||
teams: 1,
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
detailUrl="/organization/1"
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</MemoryRouter>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
@ -44,7 +44,7 @@ describe('<TeamForm />', () => {
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
wrapper.find('input#team-name').simulate('change', {
|
||||
target: { value: 'new foo', name: 'name' },
|
||||
});
|
||||
|
||||
@ -125,7 +125,7 @@ export function mountWithContexts(node, options = {}) {
|
||||
const context = applyDefaultContexts(options.context);
|
||||
const childContextTypes = {
|
||||
linguiPublisher: shape({
|
||||
i18n: object.isRequired,
|
||||
i18n: object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
}).isRequired,
|
||||
config: shape({
|
||||
ansible_version: string,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user