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:
softwarefactory-project-zuul[bot] 2020-12-18 17:44:43 +00:00 committed by GitHub
commit 5632f72bb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 5424 additions and 4285 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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,

View File

@ -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,

View File

@ -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: [],
};

View File

@ -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>
))}

View File

@ -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,

View 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>
);
}

View File

@ -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('');
});
});

View 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>
);
}

View 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>;
}

View 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);
});
});

View 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);

View File

@ -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');
});
});

View 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';

View File

@ -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;

View File

@ -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: [],

View File

@ -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,
};

View File

@ -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'

View File

@ -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);

View File

@ -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);
});

View File

@ -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);

View File

@ -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,

View File

@ -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,
};

View File

@ -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,

View File

@ -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: '---',
};

View File

@ -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)}

View File

@ -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();
});

View File

@ -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>
);
}

View File

@ -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>
);

View File

@ -44,7 +44,7 @@ describe('<TeamForm />', () => {
);
});
act(() => {
await act(async () => {
wrapper.find('input#team-name').simulate('change', {
target: { value: 'new foo', name: 'name' },
});

View File

@ -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,