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
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/patternfly": "4.70.2",
"@patternfly/react-core": "4.84.3", "@patternfly/react-core": "4.84.3",
"@patternfly/react-icons": "4.7.22", "@patternfly/react-icons": "4.7.22",
"@patternfly/react-table": "^4.19.15",
"ansi-to-html": "^0.6.11", "ansi-to-html": "^0.6.11",
"axios": "^0.18.1", "axios": "^0.18.1",
"codemirror": "^5.47.0", "codemirror": "^5.47.0",

View File

@@ -93,9 +93,11 @@ function DataListToolbar({
onRemove={onRemove} onRemove={onRemove}
/> />
</ToolbarItem> </ToolbarItem>
<ToolbarItem> {sortColumns && (
<Sort qsConfig={qsConfig} columns={sortColumns} onSort={onSort} /> <ToolbarItem>
</ToolbarItem> <Sort qsConfig={qsConfig} columns={sortColumns} onSort={onSort} />
</ToolbarItem>
)}
</ToolbarToggleGroup> </ToolbarToggleGroup>
{showExpandCollapse && ( {showExpandCollapse && (
<ToolbarGroup> <ToolbarGroup>
@@ -157,7 +159,7 @@ DataListToolbar.propTypes = {
searchColumns: SearchColumns.isRequired, searchColumns: SearchColumns.isRequired,
searchableKeys: PropTypes.arrayOf(PropTypes.string), searchableKeys: PropTypes.arrayOf(PropTypes.string),
relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string), relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
sortColumns: SortColumns.isRequired, sortColumns: SortColumns,
showSelectAll: PropTypes.bool, showSelectAll: PropTypes.bool,
isAllSelected: PropTypes.bool, isAllSelected: PropTypes.bool,
isCompact: PropTypes.bool, isCompact: PropTypes.bool,
@@ -174,6 +176,7 @@ DataListToolbar.defaultProps = {
itemCount: 0, itemCount: 0,
searchableKeys: [], searchableKeys: [],
relatedSearchableKeys: [], relatedSearchableKeys: [],
sortColumns: null,
clearAllFilters: null, clearAllFilters: null,
showSelectAll: false, showSelectAll: false,
isAllSelected: false, isAllSelected: false,

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useContext } from 'react'; import React, { useState, useEffect, useContext } from 'react';
import { arrayOf, func, object, string } from 'prop-types'; import { arrayOf, func, shape, string, oneOfType, number } from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Button, Tooltip, DropdownItem } from '@patternfly/react-core'; import { Button, Tooltip, DropdownItem } from '@patternfly/react-core';
@@ -149,7 +149,20 @@ DisassociateButton.defaultProps = {
}; };
DisassociateButton.propTypes = { DisassociateButton.propTypes = {
itemsToDisassociate: arrayOf(object), itemsToDisassociate: oneOfType([
arrayOf(
shape({
id: number.isRequired,
name: string.isRequired,
})
),
arrayOf(
shape({
id: number.isRequired,
hostname: string.isRequired,
})
),
]),
modalNote: string, modalNote: string,
modalTitle: string, modalTitle: string,
onDisassociate: func.isRequired, onDisassociate: func.isRequired,

View File

@@ -147,13 +147,14 @@ ListHeader.propTypes = {
searchColumns: SearchColumns.isRequired, searchColumns: SearchColumns.isRequired,
searchableKeys: PropTypes.arrayOf(PropTypes.string), searchableKeys: PropTypes.arrayOf(PropTypes.string),
relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string), relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
sortColumns: SortColumns.isRequired, sortColumns: SortColumns,
renderToolbar: PropTypes.func, renderToolbar: PropTypes.func,
}; };
ListHeader.defaultProps = { ListHeader.defaultProps = {
renderToolbar: props => <DataListToolbar {...props} />, renderToolbar: props => <DataListToolbar {...props} />,
searchableKeys: [], searchableKeys: [],
sortColumns: null,
relatedSearchableKeys: [], relatedSearchableKeys: [],
}; };

View File

@@ -279,8 +279,8 @@ function HostFilterLookup({
numChips={5} numChips={5}
totalChips={chips[key]?.chips?.length || 0} totalChips={chips[key]?.chips?.length || 0}
> >
{chips[key]?.chips?.map((chip, index) => ( {chips[key]?.chips?.map(chip => (
<Chip key={index} isReadOnly> <Chip key={chip.key} isReadOnly>
{chip.node} {chip.node}
</Chip> </Chip>
))} ))}

View File

@@ -1,10 +1,11 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { arrayOf, string, func, object, bool } from 'prop-types'; import { arrayOf, string, func, bool } from 'prop-types';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core'; import { FormGroup } from '@patternfly/react-core';
import { InstanceGroupsAPI } from '../../api'; import { InstanceGroupsAPI } from '../../api';
import { InstanceGroup } from '../../types';
import { getQSConfig, parseQueryString } from '../../util/qs'; import { getQSConfig, parseQueryString } from '../../util/qs';
import Popover from '../Popover'; import Popover from '../Popover';
import OptionsList from '../OptionsList'; import OptionsList from '../OptionsList';
@@ -120,7 +121,7 @@ function InstanceGroupsLookup(props) {
} }
InstanceGroupsLookup.propTypes = { InstanceGroupsLookup.propTypes = {
value: arrayOf(object).isRequired, value: arrayOf(InstanceGroup).isRequired,
tooltip: string, tooltip: string,
onChange: func.isRequired, onChange: func.isRequired,
className: string, className: string,

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 React from 'react';
import { shape, string, number, arrayOf, node, oneOfType } from 'prop-types'; import { shape, string, number, arrayOf, node, oneOfType } from 'prop-types';
import { Tab, Tabs, TabTitleText } from '@patternfly/react-core'; import { Tab, Tabs, TabTitleText } from '@patternfly/react-core';
import { useHistory } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
function RoutedTabs(props) { function RoutedTabs(props) {
const { tabsArray } = props; const { tabsArray } = props;
const history = useHistory(); const history = useHistory();
const location = useLocation();
const getActiveTabId = () => { const getActiveTabId = () => {
const match = tabsArray.find(tab => tab.link === history.location.pathname); const match = tabsArray.find(tab => tab.link === location.pathname);
if (match) { if (match) {
return match.id; return match.id;
} }
const subpathMatch = tabsArray.find(tab => const subpathMatch = tabsArray.find(tab =>
history.location.pathname.startsWith(tab.link) location.pathname.startsWith(tab.link)
); );
if (subpathMatch) { if (subpathMatch) {
return subpathMatch.id; return subpathMatch.id;

View File

@@ -1,5 +1,5 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { arrayOf, object } from 'prop-types'; import { arrayOf } from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { Link as _Link } from 'react-router-dom'; import { Link as _Link } from 'react-router-dom';
import { Tooltip } from '@patternfly/react-core'; import { Tooltip } from '@patternfly/react-core';
@@ -8,6 +8,7 @@ import { t } from '@lingui/macro';
import StatusIcon from '../StatusIcon'; import StatusIcon from '../StatusIcon';
import { formatDateString } from '../../util/dates'; import { formatDateString } from '../../util/dates';
import { JOB_TYPE_URL_SEGMENTS } from '../../constants'; import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
import { Job } from '../../types';
/* eslint-disable react/jsx-pascal-case */ /* eslint-disable react/jsx-pascal-case */
const Link = styled(props => <_Link {...props} />)` const Link = styled(props => <_Link {...props} />)`
@@ -52,7 +53,7 @@ const Sparkline = ({ i18n, jobs }) => {
}; };
Sparkline.propTypes = { Sparkline.propTypes = {
jobs: arrayOf(object), jobs: arrayOf(Job),
}; };
Sparkline.defaultProps = { Sparkline.defaultProps = {
jobs: [], jobs: [],

View File

@@ -8,6 +8,7 @@ import {
SyncAltIcon, SyncAltIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
ClockIcon, ClockIcon,
MinusCircleIcon,
} from '@patternfly/react-icons'; } from '@patternfly/react-icons';
import styled, { keyframes } from 'styled-components'; import styled, { keyframes } from 'styled-components';
@@ -32,6 +33,7 @@ const colors = {
running: 'blue', running: 'blue',
pending: 'blue', pending: 'blue',
waiting: 'grey', waiting: 'grey',
disabled: 'grey',
canceled: 'orange', canceled: 'orange',
}; };
const icons = { const icons = {
@@ -42,6 +44,7 @@ const icons = {
running: RunningIcon, running: RunningIcon,
pending: ClockIcon, pending: ClockIcon,
waiting: ClockIcon, waiting: ClockIcon,
disabled: MinusCircleIcon,
canceled: ExclamationTriangleIcon, canceled: ExclamationTriangleIcon,
}; };
@@ -66,6 +69,7 @@ StatusLabel.propTypes = {
'running', 'running',
'pending', 'pending',
'waiting', 'waiting',
'disabled',
'canceled', 'canceled',
]).isRequired, ]).isRequired,
}; };

View File

@@ -8,9 +8,11 @@ import useRequest, { useDeleteItems } from '../../../util/useRequest';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import DatalistToolbar from '../../../components/DataListToolbar'; import DatalistToolbar from '../../../components/DataListToolbar';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
import PaginatedDataList, { import { ToolbarDeleteButton } from '../../../components/PaginatedDataList';
ToolbarDeleteButton, import PaginatedTable, {
} from '../../../components/PaginatedDataList'; HeaderRow,
HeaderCell,
} from '../../../components/PaginatedTable';
import { getQSConfig, parseQueryString } from '../../../util/qs'; import { getQSConfig, parseQueryString } from '../../../util/qs';
import useWsInventories from './useWsInventories'; import useWsInventories from './useWsInventories';
import AddDropDownButton from '../../../components/AddDropDownButton'; import AddDropDownButton from '../../../components/AddDropDownButton';
@@ -149,17 +151,17 @@ function InventoryList({ i18n }) {
]} ]}
/> />
); );
return ( return (
<PageSection> <PageSection>
<Card> <Card>
<PaginatedDataList <PaginatedTable
contentError={contentError} contentError={contentError}
hasContentLoading={hasContentLoading} hasContentLoading={hasContentLoading}
items={inventories} items={inventories}
itemCount={itemCount} itemCount={itemCount}
pluralizedItemName={i18n._(t`Inventories`)} pluralizedItemName={i18n._(t`Inventories`)}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
onRowClick={handleSelect}
toolbarSearchColumns={[ toolbarSearchColumns={[
{ {
name: i18n._(t`Name`), name: i18n._(t`Name`),
@@ -187,6 +189,18 @@ function InventoryList({ i18n }) {
]} ]}
toolbarSearchableKeys={searchableKeys} toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys} toolbarRelatedSearchableKeys={relatedSearchableKeys}
headerRow={
<HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
<HeaderCell>{i18n._(t`Status`)}</HeaderCell>
<HeaderCell>{i18n._(t`Type`)}</HeaderCell>
<HeaderCell>{i18n._(t`Organization`)}</HeaderCell>
<HeaderCell>{i18n._(t`Groups`)}</HeaderCell>
<HeaderCell>{i18n._(t`Hosts`)}</HeaderCell>
<HeaderCell>{i18n._(t`Sources`)}</HeaderCell>
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
</HeaderRow>
}
renderToolbar={props => ( renderToolbar={props => (
<DatalistToolbar <DatalistToolbar
{...props} {...props}
@@ -209,11 +223,12 @@ function InventoryList({ i18n }) {
]} ]}
/> />
)} )}
renderItem={inventory => ( renderRow={(inventory, index) => (
<InventoryListItem <InventoryListItem
key={inventory.id} key={inventory.id}
value={inventory.name} value={inventory.name}
inventory={inventory} inventory={inventory}
rowIndex={index}
fetchInventories={fetchInventories} fetchInventories={fetchInventories}
detailUrl={ detailUrl={
inventory.kind === 'smart' inventory.kind === 'smart'

View File

@@ -1,50 +1,21 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { string, bool, func } from 'prop-types'; import { string, bool, func } from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { import { Button, Label } from '@patternfly/react-core';
Button, import { Tr, Td } from '@patternfly/react-table';
DataListAction as _DataListAction,
DataListCheck,
DataListItem,
DataListItemCells,
DataListItemRow,
Label,
Tooltip,
Badge as PFBadge,
} from '@patternfly/react-core';
import { PencilAltIcon } from '@patternfly/react-icons'; import { PencilAltIcon } from '@patternfly/react-icons';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { timeOfDay } from '../../../util/dates'; import { timeOfDay } from '../../../util/dates';
import { InventoriesAPI } from '../../../api'; import { InventoriesAPI } from '../../../api';
import { Inventory } from '../../../types'; import { Inventory } from '../../../types';
import DataListCell from '../../../components/DataListCell'; import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
import CopyButton from '../../../components/CopyButton'; import CopyButton from '../../../components/CopyButton';
import SyncStatusIndicator from '../../../components/SyncStatusIndicator'; import StatusLabel from '../../../components/StatusLabel';
const DataListAction = styled(_DataListAction)`
align-items: center;
display: grid;
grid-gap: 16px;
grid-template-columns: repeat(2, 40px);
`;
const Badge = styled(PFBadge)`
margin-left: 8px;
`;
const ListGroup = styled.div`
margin-left: 8px;
display: inline-block;
`;
const OrgLabel = styled.b`
margin-right: 20px;
`;
function InventoryListItem({ function InventoryListItem({
inventory, inventory,
rowIndex,
isSelected, isSelected,
onSelect, onSelect,
detailUrl, detailUrl,
@@ -85,108 +56,84 @@ function InventoryListItem({
} }
return ( return (
<DataListItem <Tr id={inventory.id} aria-labelledby={labelId}>
key={inventory.id} <Td
aria-labelledby={labelId} select={{
id={`${inventory.id}`} rowIndex,
> isSelected,
<DataListItemRow> onSelect,
<DataListCheck }}
id={`select-inventory-${inventory.id}`} dataLabel={i18n._(t`Selected`)}
isDisabled={inventory.pending_deletion} />
checked={isSelected} <Td id={labelId} dataLabel={i18n._(t`Name`)}>
onChange={onSelect} {inventory.pending_deletion ? (
aria-labelledby={labelId} <b>{inventory.name}</b>
/> ) : (
<DataListItemCells <Link to={`${detailUrl}`}>
dataListCells={[ <b>{inventory.name}</b>
<DataListCell key="sync-status" isIcon> </Link>
{inventory.kind !== 'smart' && (
<SyncStatusIndicator status={syncStatus} />
)}
</DataListCell>,
<DataListCell key="name">
{inventory.pending_deletion ? (
<b>{inventory.name}</b>
) : (
<Link to={`${detailUrl}`}>
<b>{inventory.name}</b>
</Link>
)}
</DataListCell>,
<DataListCell key="kind">
{inventory.kind === 'smart'
? i18n._(t`Smart Inventory`)
: i18n._(t`Inventory`)}
</DataListCell>,
<DataListCell key="organization">
<OrgLabel>{i18n._(t`Organization`)}</OrgLabel>
<Link
to={`/organizations/${inventory.summary_fields.organization.id}/details`}
>
{inventory.summary_fields.organization.name}
</Link>
</DataListCell>,
<DataListCell key="groups-hosts-sources-counts">
<ListGroup>
{i18n._(t`Groups`)}
<Badge isRead>{inventory.total_groups}</Badge>
</ListGroup>
<ListGroup>
{i18n._(t`Hosts`)}
<Badge isRead>{inventory.total_hosts}</Badge>
</ListGroup>
<ListGroup>
{i18n._(t`Sources`)}
<Badge isRead>{inventory.total_inventory_sources}</Badge>
</ListGroup>
</DataListCell>,
inventory.pending_deletion && (
<DataListCell alignRight isFilled={false} key="pending-delete">
<Label color="red">{i18n._(t`Pending delete`)}</Label>
</DataListCell>
),
]}
/>
{!inventory.pending_deletion && (
<DataListAction
aria-label="actions"
aria-labelledby={labelId}
id={labelId}
>
{inventory.summary_fields.user_capabilities.edit ? (
<Tooltip content={i18n._(t`Edit Inventory`)} position="top">
<Button
isDisabled={isDisabled}
aria-label={i18n._(t`Edit Inventory`)}
variant="plain"
component={Link}
to={`/inventories/${
inventory.kind === 'smart' ? 'smart_inventory' : 'inventory'
}/${inventory.id}/edit`}
>
<PencilAltIcon />
</Button>
</Tooltip>
) : (
''
)}
{inventory.summary_fields.user_capabilities.copy && (
<CopyButton
copyItem={copyInventory}
isDisabled={isDisabled}
onCopyStart={handleCopyStart}
onCopyFinish={handleCopyFinish}
helperText={{
tooltip: i18n._(t`Copy Inventory`),
errorMessage: i18n._(t`Failed to copy inventory.`),
}}
/>
)}
</DataListAction>
)} )}
</DataListItemRow> </Td>
</DataListItem> <Td dataLabel={i18n._(t`Status`)}>
{inventory.kind !== 'smart' && <StatusLabel status={syncStatus} />}
</Td>
<Td dataLabel={i18n._(t`Type`)}>
{inventory.kind === 'smart'
? i18n._(t`Smart Inventory`)
: i18n._(t`Inventory`)}
</Td>
<Td key="organization" dataLabel={i18n._(t`Organization`)}>
<Link
to={`/organizations/${inventory?.summary_fields?.organization?.id}/details`}
>
{inventory?.summary_fields?.organization?.name}
</Link>
</Td>
<Td dataLabel={i18n._(t`Groups`)}>{inventory.total_groups}</Td>
<Td dataLabel={i18n._(t`Hosts`)}>{inventory.total_hosts}</Td>
<Td dataLabel={i18n._(t`Sources`)}>
{inventory.total_inventory_sources}
</Td>
{inventory.pending_deletion ? (
<Td dataLabel={i18n._(t`Groups`)}>
<Label color="red">{i18n._(t`Pending delete`)}</Label>
</Td>
) : (
<ActionsTd dataLabel={i18n._(t`Actions`)}>
<ActionItem
visible={inventory.summary_fields.user_capabilities.edit}
tooltip={i18n._(t`Edit Inventory`)}
>
<Button
isDisabled={isDisabled}
aria-label={i18n._(t`Edit Inventory`)}
variant="plain"
component={Link}
to={`/inventories/${
inventory.kind === 'smart' ? 'smart_inventory' : 'inventory'
}/${inventory.id}/edit`}
>
<PencilAltIcon />
</Button>
</ActionItem>
<ActionItem
visible={inventory.summary_fields.user_capabilities.copy}
tooltip={i18n._(t`Copy Inventory`)}
>
<CopyButton
copyItem={copyInventory}
isDisabled={isDisabled}
onCopyStart={handleCopyStart}
onCopyFinish={handleCopyFinish}
helperText={{
tooltip: i18n._(t`Copy Inventory`),
errorMessage: i18n._(t`Failed to copy inventory.`),
}}
/>
</ActionItem>
</ActionsTd>
)}
</Tr>
); );
} }
export default withI18n()(InventoryListItem); export default withI18n()(InventoryListItem);

View File

@@ -9,145 +9,167 @@ jest.mock('../../../api/models/Inventories');
describe('<InventoryListItem />', () => { describe('<InventoryListItem />', () => {
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts( mountWithContexts(
<InventoryListItem <table>
inventory={{ <tbody>
id: 1, <InventoryListItem
name: 'Inventory', inventory={{
summary_fields: {
organization: {
id: 1, id: 1,
name: 'Default', name: 'Inventory',
}, summary_fields: {
user_capabilities: { organization: {
edit: true, id: 1,
}, name: 'Default',
}, },
}} user_capabilities: {
detailUrl="/inventories/inventory/1" edit: true,
isSelected },
onSelect={() => {}} },
/> }}
detailUrl="/inventories/inventory/1"
isSelected
onSelect={() => {}}
/>
</tbody>
</table>
); );
}); });
test('should render prompt list item data', () => { test('should render prompt list item data', () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<InventoryListItem <table>
inventory={{ <tbody>
id: 1, <InventoryListItem
name: 'Inventory', inventory={{
kind: '',
summary_fields: {
organization: {
id: 1, id: 1,
name: 'Default', name: 'Inventory',
}, kind: '',
user_capabilities: { summary_fields: {
edit: true, organization: {
}, id: 1,
}, name: 'Default',
}} },
detailUrl="/inventories/inventory/1" user_capabilities: {
isSelected edit: true,
onSelect={() => {}} },
/> },
}}
detailUrl="/inventories/inventory/1"
isSelected
onSelect={() => {}}
/>
</tbody>
</table>
); );
expect(wrapper.find('SyncStatusIndicator').length).toBe(1); expect(wrapper.find('StatusLabel').length).toBe(1);
expect( expect(
wrapper wrapper
.find('DataListCell') .find('Td')
.at(1) .at(1)
.text() .text()
).toBe('Inventory'); ).toBe('Inventory');
expect( expect(
wrapper wrapper
.find('DataListCell') .find('Td')
.at(2) .at(2)
.text() .text()
).toBe('Disabled');
expect(
wrapper
.find('Td')
.at(3)
.text()
).toBe('Inventory'); ).toBe('Inventory');
expect( expect(
wrapper wrapper
.find('DataListCell') .find('Td')
.at(3)
.text()
).toBe('OrganizationDefault');
expect(
wrapper
.find('DataListCell')
.at(4) .at(4)
.text() .text()
).toBe('GroupsHostsSources'); ).toBe('Default');
}); });
test('edit button shown to users with edit capabilities', () => { test('edit button shown to users with edit capabilities', () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<InventoryListItem <table>
inventory={{ <tbody>
id: 1, <InventoryListItem
name: 'Inventory', inventory={{
summary_fields: {
organization: {
id: 1, id: 1,
name: 'Default', name: 'Inventory',
}, summary_fields: {
user_capabilities: { organization: {
edit: true, id: 1,
}, name: 'Default',
}, },
}} user_capabilities: {
detailUrl="/inventories/inventory/1" edit: true,
isSelected },
onSelect={() => {}} },
/> }}
detailUrl="/inventories/inventory/1"
isSelected
onSelect={() => {}}
/>
</tbody>
</table>
); );
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
}); });
test('edit button hidden from users without edit capabilities', () => { test('edit button hidden from users without edit capabilities', () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<InventoryListItem <table>
inventory={{ <tbody>
id: 1, <InventoryListItem
name: 'Inventory', inventory={{
summary_fields: {
organization: {
id: 1, id: 1,
name: 'Default', name: 'Inventory',
}, summary_fields: {
user_capabilities: { organization: {
edit: false, id: 1,
}, name: 'Default',
}, },
}} user_capabilities: {
detailUrl="/inventories/inventory/1" edit: false,
isSelected },
onSelect={() => {}} },
/> }}
detailUrl="/inventories/inventory/1"
isSelected
onSelect={() => {}}
/>
</tbody>
</table>
); );
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
}); });
test('should call api to copy inventory', async () => { test('should call api to copy inventory', async () => {
InventoriesAPI.copy.mockResolvedValue(); InventoriesAPI.copy.mockResolvedValue();
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<InventoryListItem <table>
inventory={{ <tbody>
id: 1, <InventoryListItem
name: 'Inventory', inventory={{
summary_fields: {
organization: {
id: 1, id: 1,
name: 'Default', name: 'Inventory',
}, summary_fields: {
user_capabilities: { organization: {
edit: false, id: 1,
copy: true, name: 'Default',
}, },
}, user_capabilities: {
}} edit: false,
detailUrl="/inventories/inventory/1" copy: true,
isSelected },
onSelect={() => {}} },
/> }}
detailUrl="/inventories/inventory/1"
isSelected
onSelect={() => {}}
/>
</tbody>
</table>
); );
await act(async () => await act(async () =>
@@ -161,25 +183,29 @@ describe('<InventoryListItem />', () => {
InventoriesAPI.copy.mockRejectedValue(new Error()); InventoriesAPI.copy.mockRejectedValue(new Error());
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<InventoryListItem <table>
inventory={{ <tbody>
id: 1, <InventoryListItem
name: 'Inventory', inventory={{
summary_fields: {
organization: {
id: 1, id: 1,
name: 'Default', name: 'Inventory',
}, summary_fields: {
user_capabilities: { organization: {
edit: false, id: 1,
copy: true, name: 'Default',
}, },
}, user_capabilities: {
}} edit: false,
detailUrl="/inventories/inventory/1" copy: true,
isSelected },
onSelect={() => {}} },
/> }}
detailUrl="/inventories/inventory/1"
isSelected
onSelect={() => {}}
/>
</tbody>
</table>
); );
await act(async () => await act(async () =>
wrapper.find('Button[aria-label="Copy"]').prop('onClick')() wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
@@ -191,25 +217,29 @@ describe('<InventoryListItem />', () => {
test('should not render copy button', async () => { test('should not render copy button', async () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<InventoryListItem <table>
inventory={{ <tbody>
id: 1, <InventoryListItem
name: 'Inventory', inventory={{
summary_fields: {
organization: {
id: 1, id: 1,
name: 'Default', name: 'Inventory',
}, summary_fields: {
user_capabilities: { organization: {
edit: false, id: 1,
copy: false, name: 'Default',
}, },
}, user_capabilities: {
}} edit: false,
detailUrl="/inventories/inventory/1" copy: false,
isSelected },
onSelect={() => {}} },
/> }}
detailUrl="/inventories/inventory/1"
isSelected
onSelect={() => {}}
/>
</tbody>
</table>
); );
expect(wrapper.find('CopyButton').length).toBe(0); expect(wrapper.find('CopyButton').length).toBe(0);
}); });

View File

@@ -47,7 +47,13 @@ describe('<SmartInventoryEdit />', () => {
data: { actions: { POST: true } }, data: { actions: { POST: true } },
}); });
InventoriesAPI.readInstanceGroups.mockResolvedValue({ InventoriesAPI.readInstanceGroups.mockResolvedValue({
data: { count: 0, results: [{ id: 10 }, { id: 20 }] }, data: {
count: 0,
results: [
{ id: 10, name: 'instance-group-10' },
{ id: 20, name: 'instance-group-20' },
],
},
}); });
history = createMemoryHistory({ history = createMemoryHistory({
initialEntries: [`/inventories/smart_inventory/${mockSmartInv.id}/edit`], initialEntries: [`/inventories/smart_inventory/${mockSmartInv.id}/edit`],
@@ -85,7 +91,10 @@ describe('<SmartInventoryEdit />', () => {
await act(async () => { await act(async () => {
wrapper.find('SmartInventoryForm').invoke('onSubmit')({ wrapper.find('SmartInventoryForm').invoke('onSubmit')({
...mockSmartInv, ...mockSmartInv,
instance_groups: [{ id: 10 }, { id: 30 }], instance_groups: [
{ id: 10, name: 'instance-group-10' },
{ id: 30, name: 'instance-group-30' },
],
}); });
}); });
expect(InventoriesAPI.update).toHaveBeenCalledTimes(1); expect(InventoriesAPI.update).toHaveBeenCalledTimes(1);

View File

@@ -96,8 +96,8 @@ describe('<InventoryForm />', () => {
expect(wrapper.find('VariablesField[label="Variables"]').length).toBe(1); expect(wrapper.find('VariablesField[label="Variables"]').length).toBe(1);
}); });
test('should update form values', () => { test('should update form values', async () => {
act(() => { await act(async () => {
wrapper.find('OrganizationLookup').invoke('onBlur')(); wrapper.find('OrganizationLookup').invoke('onBlur')();
wrapper.find('OrganizationLookup').invoke('onChange')({ wrapper.find('OrganizationLookup').invoke('onChange')({
id: 3, id: 3,

View File

@@ -1,13 +1,14 @@
import 'styled-components/macro'; import 'styled-components/macro';
import React, { useState, useContext, useEffect } from 'react'; import React, { useState, useContext, useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { func, bool, arrayOf, object } from 'prop-types'; import { func, bool, arrayOf } from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Button, Radio, DropdownItem } from '@patternfly/react-core'; import { Button, Radio, DropdownItem } from '@patternfly/react-core';
import styled from 'styled-components'; import styled from 'styled-components';
import { KebabifiedContext } from '../../../contexts/Kebabified'; import { KebabifiedContext } from '../../../contexts/Kebabified';
import { GroupsAPI, InventoriesAPI } from '../../../api'; import { GroupsAPI, InventoriesAPI } from '../../../api';
import { Group } from '../../../types';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
@@ -158,7 +159,7 @@ const InventoryGroupsDeleteModal = ({
InventoryGroupsDeleteModal.propTypes = { InventoryGroupsDeleteModal.propTypes = {
onAfterDelete: func.isRequired, onAfterDelete: func.isRequired,
groups: arrayOf(object), groups: arrayOf(Group),
isDisabled: bool.isRequired, isDisabled: bool.isRequired,
}; };

View File

@@ -2,8 +2,9 @@ import React, { useEffect, useCallback } from 'react';
import { Formik, useField, useFormikContext } from 'formik'; import { Formik, useField, useFormikContext } from 'formik';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { func, shape, object, arrayOf } from 'prop-types'; import { func, shape, arrayOf } from 'prop-types';
import { Form } from '@patternfly/react-core'; import { Form } from '@patternfly/react-core';
import { InstanceGroup } from '../../../types';
import { VariablesField } from '../../../components/CodeMirrorInput'; import { VariablesField } from '../../../components/CodeMirrorInput';
import ContentError from '../../../components/ContentError'; import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading'; import ContentLoading from '../../../components/ContentLoading';
@@ -168,7 +169,7 @@ function SmartInventoryForm({
} }
SmartInventoryForm.propTypes = { SmartInventoryForm.propTypes = {
instanceGroups: arrayOf(object), instanceGroups: arrayOf(InstanceGroup),
inventory: shape({}), inventory: shape({}),
onCancel: func.isRequired, onCancel: func.isRequired,
onSubmit: func.isRequired, onSubmit: func.isRequired,

View File

@@ -27,7 +27,7 @@ const mockFormValues = {
organization: { id: 1, name: 'mock organization' }, organization: { id: 1, name: 'mock organization' },
host_filter: host_filter:
'name__icontains=mock and name__icontains=foo and groups__name__icontains=mock group', 'name__icontains=mock and name__icontains=foo and groups__name__icontains=mock group',
instance_groups: [{ id: 123 }], instance_groups: [{ id: 123, name: 'mock instance group' }],
variables: '---', variables: '---',
}; };

View File

@@ -9,10 +9,14 @@ import useRequest, { useDeleteItems } from '../../../util/useRequest';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import DataListToolbar from '../../../components/DataListToolbar'; import DataListToolbar from '../../../components/DataListToolbar';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
import PaginatedDataList, { import {
ToolbarAddButton, ToolbarAddButton,
ToolbarDeleteButton, ToolbarDeleteButton,
} from '../../../components/PaginatedDataList'; } from '../../../components/PaginatedDataList';
import PaginatedTable, {
HeaderRow,
HeaderCell,
} from '../../../components/PaginatedTable';
import { getQSConfig, parseQueryString } from '../../../util/qs'; import { getQSConfig, parseQueryString } from '../../../util/qs';
import OrganizationListItem from './OrganizationListItem'; import OrganizationListItem from './OrganizationListItem';
@@ -117,14 +121,13 @@ function OrganizationsList({ i18n }) {
<> <>
<PageSection> <PageSection>
<Card> <Card>
<PaginatedDataList <PaginatedTable
contentError={contentError} contentError={contentError}
hasContentLoading={hasContentLoading} hasContentLoading={hasContentLoading}
items={organizations} items={organizations}
itemCount={organizationCount} itemCount={organizationCount}
pluralizedItemName={i18n._(t`Organizations`)} pluralizedItemName={i18n._(t`Organizations`)}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
onRowClick={handleSelect}
toolbarSearchColumns={[ toolbarSearchColumns={[
{ {
name: i18n._(t`Name`), name: i18n._(t`Name`),
@@ -144,14 +147,16 @@ function OrganizationsList({ i18n }) {
key: 'modified_by__username__icontains', key: 'modified_by__username__icontains',
}, },
]} ]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
toolbarSearchableKeys={searchableKeys} toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys} toolbarRelatedSearchableKeys={relatedSearchableKeys}
headerRow={
<HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
<HeaderCell>{i18n._(t`Members`)}</HeaderCell>
<HeaderCell>{i18n._(t`Teams`)}</HeaderCell>
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
</HeaderRow>
}
renderToolbar={props => ( renderToolbar={props => (
<DataListToolbar <DataListToolbar
{...props} {...props}
@@ -172,10 +177,11 @@ function OrganizationsList({ i18n }) {
]} ]}
/> />
)} )}
renderItem={o => ( renderRow={(o, index) => (
<OrganizationListItem <OrganizationListItem
key={o.id} key={o.id}
organization={o} organization={o}
rowIndex={index}
detailUrl={`${match.url}/${o.id}`} detailUrl={`${match.url}/${o.id}`}
isSelected={selected.some(row => row.id === o.id)} isSelected={selected.some(row => row.id === o.id)}
onSelect={() => handleSelect(o)} onSelect={() => handleSelect(o)}

View File

@@ -103,7 +103,7 @@ describe('<OrganizationsList />', () => {
}); });
test('Item appears selected after selecting it', async () => { test('Item appears selected after selecting it', async () => {
const itemCheckboxInput = 'input#select-organization-1'; const itemCheckboxInput = 'tr#org-row-1 input[type="checkbox"]';
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<OrganizationsList />); wrapper = mountWithContexts(<OrganizationsList />);
}); });
@@ -115,7 +115,6 @@ describe('<OrganizationsList />', () => {
await act(async () => { await act(async () => {
wrapper wrapper
.find(itemCheckboxInput) .find(itemCheckboxInput)
.closest('DataListCheck')
.props() .props()
.onChange(); .onChange();
}); });
@@ -128,9 +127,9 @@ describe('<OrganizationsList />', () => {
test('All items appear selected after select-all and unselected after unselect-all', async () => { test('All items appear selected after select-all and unselected after unselect-all', async () => {
const itemCheckboxInputs = [ const itemCheckboxInputs = [
'input#select-organization-1', 'tr#org-row-1 input[type="checkbox"]',
'input#select-organization-2', 'tr#org-row-2 input[type="checkbox"]',
'input#select-organization-3', 'tr#org-row-3 input[type="checkbox"]',
]; ];
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<OrganizationsList />); wrapper = mountWithContexts(<OrganizationsList />);
@@ -227,7 +226,7 @@ describe('<OrganizationsList />', () => {
}); });
test('Error dialog shown for failed deletion', async () => { test('Error dialog shown for failed deletion', async () => {
const itemCheckboxInput = 'input#select-organization-1'; const itemCheckboxInput = 'tr#org-row-1 input[type="checkbox"]';
OrganizationsAPI.destroy.mockRejectedValue( OrganizationsAPI.destroy.mockRejectedValue(
new Error({ new Error({
response: { response: {
@@ -250,7 +249,6 @@ describe('<OrganizationsList />', () => {
await act(async () => { await act(async () => {
wrapper wrapper
.find(itemCheckboxInput) .find(itemCheckboxInput)
.closest('DataListCheck')
.props() .props()
.onChange(); .onChange();
}); });

View File

@@ -2,105 +2,61 @@ import React from 'react';
import { string, bool, func } from 'prop-types'; import { string, bool, func } from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { import { Button } from '@patternfly/react-core';
Badge as PFBadge, import { Tr, Td } from '@patternfly/react-table';
Button,
DataListAction as _DataListAction,
DataListCheck,
DataListItem,
DataListItemCells,
DataListItemRow,
Tooltip,
} from '@patternfly/react-core';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { PencilAltIcon } from '@patternfly/react-icons'; import { PencilAltIcon } from '@patternfly/react-icons';
import DataListCell from '../../../components/DataListCell'; import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
import { Organization } from '../../../types'; import { Organization } from '../../../types';
const Badge = styled(PFBadge)`
margin-left: 8px;
`;
const ListGroup = styled.span`
margin-left: 24px;
&:first-of-type {
margin-left: 0;
}
`;
const DataListAction = styled(_DataListAction)`
align-items: center;
display: grid;
grid-gap: 16px;
grid-template-columns: 40px;
`;
function OrganizationListItem({ function OrganizationListItem({
organization, organization,
isSelected, isSelected,
onSelect, onSelect,
rowIndex,
detailUrl, detailUrl,
i18n, i18n,
}) { }) {
const labelId = `check-action-${organization.id}`; const labelId = `check-action-${organization.id}`;
return ( return (
<DataListItem <Tr id={`org-row-${organization.id}`}>
key={organization.id} <Td
aria-labelledby={labelId} select={{
id={`${organization.id}`} rowIndex,
> isSelected,
<DataListItemRow> onSelect,
<DataListCheck disable: false,
id={`select-organization-${organization.id}`} }}
checked={isSelected} dataLabel={i18n._(t`Selected`)}
onChange={onSelect} />
aria-labelledby={labelId} <Td id={labelId} dataLabel={i18n._(t`Name`)}>
/> <Link to={`${detailUrl}`}>
<DataListItemCells <b>{organization.name}</b>
dataListCells={[ </Link>
<DataListCell key="name" id={labelId}> </Td>
<Link to={`${detailUrl}`}> <Td dataLabel={i18n._(t`Members`)}>
<b>{organization.name}</b> {organization.summary_fields.related_field_counts.users}
</Link> </Td>
</DataListCell>, <Td dataLabel={i18n._(t`Teams`)}>
<DataListCell key="related-field-counts"> {organization.summary_fields.related_field_counts.teams}
<ListGroup> </Td>
{i18n._(t`Members`)} <ActionsTd dataLabel={i18n._(t`Actions`)}>
<Badge isRead> <ActionItem
{organization.summary_fields.related_field_counts.users} visible={organization.summary_fields.user_capabilities.edit}
</Badge> tooltip={i18n._(t`Edit Organization`)}
</ListGroup> >
<ListGroup> <Button
{i18n._(t`Teams`)} aria-label={i18n._(t`Edit Organization`)}
<Badge isRead> variant="plain"
{organization.summary_fields.related_field_counts.teams} component={Link}
</Badge> to={`/organizations/${organization.id}/edit`}
</ListGroup> >
</DataListCell>, <PencilAltIcon />
]} </Button>
/> </ActionItem>
<DataListAction aria-label="actions" aria-labelledby={labelId}> </ActionsTd>
{organization.summary_fields.user_capabilities.edit ? ( </Tr>
<Tooltip content={i18n._(t`Edit Organization`)} position="top">
<Button
aria-label={i18n._(t`Edit Organization`)}
variant="plain"
component={Link}
to={`/organizations/${organization.id}/edit`}
>
<PencilAltIcon />
</Button>
</Tooltip>
) : (
''
)}
</DataListAction>
</DataListItemRow>
</DataListItem>
); );
} }

View File

@@ -11,77 +11,91 @@ describe('<OrganizationListItem />', () => {
mountWithContexts( mountWithContexts(
<I18nProvider> <I18nProvider>
<MemoryRouter initialEntries={['/organizations']} initialIndex={0}> <MemoryRouter initialEntries={['/organizations']} initialIndex={0}>
<OrganizationListItem <table>
organization={{ <tbody>
id: 1, <OrganizationListItem
name: 'Org', organization={{
summary_fields: { id: 1,
related_field_counts: { name: 'Org',
users: 1, summary_fields: {
teams: 1, related_field_counts: {
}, users: 1,
user_capabilities: { teams: 1,
edit: true, },
}, user_capabilities: {
}, edit: true,
}} },
detailUrl="/organization/1" },
isSelected }}
onSelect={() => {}} detailUrl="/organization/1"
/> isSelected
onSelect={() => {}}
/>
</tbody>
</table>
</MemoryRouter> </MemoryRouter>
</I18nProvider> </I18nProvider>
); );
}); });
test('edit button shown to users with edit capabilities', () => { test('edit button shown to users with edit capabilities', () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<I18nProvider> <I18nProvider>
<MemoryRouter initialEntries={['/organizations']} initialIndex={0}> <MemoryRouter initialEntries={['/organizations']} initialIndex={0}>
<OrganizationListItem <table>
organization={{ <tbody>
id: 1, <OrganizationListItem
name: 'Org', organization={{
summary_fields: { id: 1,
related_field_counts: { name: 'Org',
users: 1, summary_fields: {
teams: 1, related_field_counts: {
}, users: 1,
user_capabilities: { teams: 1,
edit: true, },
}, user_capabilities: {
}, edit: true,
}} },
detailUrl="/organization/1" },
isSelected }}
onSelect={() => {}} detailUrl="/organization/1"
/> isSelected
onSelect={() => {}}
/>
</tbody>
</table>
</MemoryRouter> </MemoryRouter>
</I18nProvider> </I18nProvider>
); );
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
}); });
test('edit button hidden from users without edit capabilities', () => { test('edit button hidden from users without edit capabilities', () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<I18nProvider> <I18nProvider>
<MemoryRouter initialEntries={['/organizations']} initialIndex={0}> <MemoryRouter initialEntries={['/organizations']} initialIndex={0}>
<OrganizationListItem <table>
organization={{ <tbody>
id: 1, <OrganizationListItem
name: 'Org', organization={{
summary_fields: { id: 1,
related_field_counts: { name: 'Org',
users: 1, summary_fields: {
teams: 1, related_field_counts: {
}, users: 1,
user_capabilities: { teams: 1,
edit: false, },
}, user_capabilities: {
}, edit: false,
}} },
detailUrl="/organization/1" },
isSelected }}
onSelect={() => {}} detailUrl="/organization/1"
/> isSelected
onSelect={() => {}}
/>
</tbody>
</table>
</MemoryRouter> </MemoryRouter>
</I18nProvider> </I18nProvider>
); );

View File

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

View File

@@ -125,7 +125,7 @@ export function mountWithContexts(node, options = {}) {
const context = applyDefaultContexts(options.context); const context = applyDefaultContexts(options.context);
const childContextTypes = { const childContextTypes = {
linguiPublisher: shape({ linguiPublisher: shape({
i18n: object.isRequired, i18n: object.isRequired, // eslint-disable-line react/forbid-prop-types
}).isRequired, }).isRequired,
config: shape({ config: shape({
ansible_version: string, ansible_version: string,