Convert last class components to functional components

Convert last class components to functional components
This commit is contained in:
nixocio
2021-11-04 16:47:30 -04:00
parent 19b4849345
commit 1994eaa406
11 changed files with 301 additions and 305 deletions

View File

@@ -1,4 +1,4 @@
import React, { Component } from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Checkbox as PFCheckbox } from '@patternfly/react-core'; import { Checkbox as PFCheckbox } from '@patternfly/react-core';
import styled from 'styled-components'; import styled from 'styled-components';
@@ -17,27 +17,25 @@ const Checkbox = styled(PFCheckbox)`
} }
`; `;
class CheckboxCard extends Component { function CheckboxCard(props) {
render() { const { name, description, isSelected, onSelect, itemId } = props;
const { name, description, isSelected, onSelect, itemId } = this.props; return (
return ( <CheckboxWrapper>
<CheckboxWrapper> <Checkbox
<Checkbox isChecked={isSelected}
isChecked={isSelected} onChange={onSelect}
onChange={onSelect} aria-label={name}
aria-label={name} id={`checkbox-card-${itemId}`}
id={`checkbox-card-${itemId}`} label={
label={ <>
<> <div style={{ fontWeight: 'bold' }}>{name}</div>
<div style={{ fontWeight: 'bold' }}>{name}</div> <div>{description}</div>
<div>{description}</div> </>
</> }
} value={itemId}
value={itemId} />
/> </CheckboxWrapper>
</CheckboxWrapper> );
);
}
} }
CheckboxCard.propTypes = { CheckboxCard.propTypes = {

View File

@@ -1,60 +1,45 @@
import React, { Component } from 'react'; import React from 'react';
import PropTypes, { oneOfType, string, arrayOf } from 'prop-types'; import PropTypes, { oneOfType, string, arrayOf } from 'prop-types';
import { matchPath, Link, withRouter } from 'react-router-dom'; import { matchPath, Link, useHistory } from 'react-router-dom';
import { NavExpandable, NavItem } from '@patternfly/react-core'; import { NavExpandable, NavItem } from '@patternfly/react-core';
class NavExpandableGroup extends Component { function NavExpandableGroup(props) {
constructor(props) { const history = useHistory();
super(props); const { groupId, groupTitle, routes } = props;
const { routes } = this.props;
// Extract a list of paths from the route params and store them for later. This creates // Extract a list of paths from the route params and store them for later. This creates
// an array of url paths associated with any NavItem component rendered by this component. // an array of url paths associated with any NavItem component rendered by this component.
this.navItemPaths = routes.map(({ path }) => path); const navItemPaths = routes.map(({ path }) => path);
this.isActiveGroup = this.isActiveGroup.bind(this);
this.isActivePath = this.isActivePath.bind(this);
}
isActiveGroup() { const isActive = navItemPaths.some(isActivePath);
return this.navItemPaths.some(this.isActivePath);
}
isActivePath(path) { function isActivePath(path) {
const { history } = this.props;
return Boolean(matchPath(history.location.pathname, { path })); return Boolean(matchPath(history.location.pathname, { path }));
} }
render() { if (routes.length === 1 && groupId === 'settings') {
const { groupId, groupTitle, routes } = this.props; const [{ path }] = routes;
if (routes.length === 1 && groupId === 'settings') {
const [{ path }] = routes;
return (
<NavItem itemId={groupId} isActive={this.isActivePath(path)} key={path}>
<Link to={path}>{groupTitle}</Link>
</NavItem>
);
}
return ( return (
<NavExpandable <NavItem itemId={groupId} isActive={isActivePath(path)} key={path}>
isActive={this.isActiveGroup()} <Link to={path}>{groupTitle}</Link>
isExpanded </NavItem>
groupId={groupId}
title={groupTitle}
>
{routes.map(({ path, title }) => (
<NavItem
groupId={groupId}
isActive={this.isActivePath(path)}
key={path}
>
<Link to={path}>{title}</Link>
</NavItem>
))}
</NavExpandable>
); );
} }
return (
<NavExpandable
isActive={isActive}
isExpanded
groupId={groupId}
title={groupTitle}
>
{routes.map(({ path, title }) => (
<NavItem groupId={groupId} isActive={isActivePath(path)} key={path}>
<Link to={path}>{title}</Link>
</NavItem>
))}
</NavExpandable>
);
} }
NavExpandableGroup.propTypes = { NavExpandableGroup.propTypes = {
@@ -63,4 +48,4 @@ NavExpandableGroup.propTypes = {
routes: arrayOf(PropTypes.object).isRequired, routes: arrayOf(PropTypes.object).isRequired,
}; };
export default withRouter(NavExpandableGroup); export default NavExpandableGroup;

View File

@@ -1,9 +1,11 @@
import React from 'react'; import React from 'react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter, withRouter } from 'react-router-dom';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import { Nav } from '@patternfly/react-core'; import { Nav } from '@patternfly/react-core';
import NavExpandableGroup from './NavExpandableGroup'; import _NavExpandableGroup from './NavExpandableGroup';
const NavExpandableGroup = withRouter(_NavExpandableGroup);
describe('NavExpandableGroup', () => { describe('NavExpandableGroup', () => {
test('initialization and render', () => { test('initialization and render', () => {
@@ -21,47 +23,88 @@ describe('NavExpandableGroup', () => {
/> />
</Nav> </Nav>
</MemoryRouter> </MemoryRouter>
) ).find('NavExpandableGroup');
.find('NavExpandableGroup')
.instance();
expect(component.navItemPaths).toEqual(['/foo', '/bar', '/fiz']); expect(component.find('NavItem').length).toEqual(3);
expect(component.isActiveGroup()).toEqual(true); let link = component.find('NavItem').at(0);
expect(component.find('NavItem').at(0).prop('isActive')).toBeTruthy();
expect(link.find('Link').prop('to')).toBe('/foo');
link = component.find('NavItem').at(1);
expect(link.prop('isActive')).toBeFalsy();
expect(link.find('Link').prop('to')).toBe('/bar');
link = component.find('NavItem').at(2);
expect(link.prop('isActive')).toBeFalsy();
expect(link.find('Link').prop('to')).toBe('/fiz');
}); });
describe('isActivePath', () => { test('when location is /foo/1/bar/fiz isActive returns false', () => {
const params = [ const component = mount(
['/fo', '/foo', false], <MemoryRouter initialEntries={['/foo/1/bar/fiz']}>
['/foo', '/foo', true], <Nav aria-label="Test Navigation">
['/foo/1/bar/fiz', '/foo', true], <NavExpandableGroup
['/foo/1/bar/fiz', 'foo', false], groupId="test"
['/foo/1/bar/fiz', 'foo/', false], groupTitle="Test"
['/foo/1/bar/fiz', '/bar', false], routes={[
['/foo/1/bar/fiz', '/fiz', false], { path: '/foo', title: 'Foo' },
]; { path: '/bar', title: 'Bar' },
{ path: '/fiz', title: 'Fiz' },
]}
/>
</Nav>
</MemoryRouter>
).find('NavExpandableGroup');
params.forEach(([location, path, expected]) => { expect(component.find('NavItem').length).toEqual(3);
test(`when location is ${location}, isActivePath('${path}') returns ${expected} `, () => { const link = component.find('NavItem').at(0);
const component = mount( expect(component.find('NavItem').at(0).prop('isActive')).toBeTruthy();
<MemoryRouter initialEntries={[location]}> expect(link.find('Link').prop('to')).toBe('/foo');
<Nav aria-label="Test Navigation"> });
<NavExpandableGroup
groupId="test"
groupTitle="Test"
routes={[
{ path: '/foo', title: 'Foo' },
{ path: '/bar', title: 'Bar' },
{ path: '/fiz', title: 'Fiz' },
]}
/>
</Nav>
</MemoryRouter>
)
.find('NavExpandableGroup')
.instance();
expect(component.isActivePath(path)).toEqual(expected); test('when location is /fo isActive returns false', () => {
}); const component = mount(
}); <MemoryRouter initialEntries={['/fo']}>
<Nav aria-label="Test Navigation">
<NavExpandableGroup
groupId="test"
groupTitle="Test"
routes={[
{ path: '/foo', title: 'Foo' },
{ path: '/bar', title: 'Bar' },
{ path: '/fiz', title: 'Fiz' },
]}
/>
</Nav>
</MemoryRouter>
).find('NavExpandableGroup');
expect(component.find('NavItem').length).toEqual(3);
const link = component.find('NavItem').at(0);
expect(component.find('NavItem').at(0).prop('isActive')).toBeFalsy();
expect(link.find('Link').prop('to')).toBe('/foo');
});
test('when location is /foo isActive returns true', () => {
const component = mount(
<MemoryRouter initialEntries={['/foo']}>
<Nav aria-label="Test Navigation">
<NavExpandableGroup
groupId="test"
groupTitle="Test"
routes={[
{ path: '/foo', title: 'Foo' },
{ path: '/bar', title: 'Bar' },
{ path: '/fiz', title: 'Fiz' },
]}
/>
</Nav>
</MemoryRouter>
).find('NavExpandableGroup');
expect(component.find('NavItem').length).toEqual(3);
const link = component.find('NavItem').at(0);
expect(component.find('NavItem').at(0).prop('isActive')).toBeTruthy();
expect(link.find('Link').prop('to')).toBe('/foo');
}); });
}); });

View File

@@ -1,24 +1,17 @@
import { Component } from 'react'; import { useEffect } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
class AppendBody extends Component { function AppendBody({ children }) {
constructor(props) { const el = document.createElement('div');
super(props);
this.el = document.createElement('div');
}
componentDidMount() { useEffect(() => {
document.body.appendChild(this.el); document.body.appendChild(el);
} return () => {
document.body.removeChild(el);
};
}, [el]);
componentWillUnmount() { return ReactDOM.createPortal(children, el);
document.body.removeChild(this.el);
}
render() {
const { children } = this.props;
return ReactDOM.createPortal(children, this.el);
}
} }
export default AppendBody; export default AppendBody;

View File

@@ -1,6 +1,6 @@
import React, { Fragment } from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import { Toolbar, ToolbarContent } from '@patternfly/react-core'; import { Toolbar, ToolbarContent } from '@patternfly/react-core';
@@ -24,122 +24,105 @@ const EmptyStateControlsWrapper = styled.div`
margin-left: 20px; margin-left: 20px;
} }
`; `;
class ListHeader extends React.Component { function ListHeader(props) {
constructor(props) { const { search, pathname } = useLocation();
super(props); const history = useHistory();
const {
emptyStateControls,
itemCount,
pagination,
qsConfig,
relatedSearchableKeys,
renderToolbar,
searchColumns,
searchableKeys,
sortColumns,
} = props;
this.handleSearch = this.handleSearch.bind(this); const handleSearch = (key, value) => {
this.handleReplaceSearch = this.handleReplaceSearch.bind(this); const params = parseQueryString(qsConfig, search);
this.handleSort = this.handleSort.bind(this); const qs = updateQueryString(qsConfig, search, {
this.handleRemove = this.handleRemove.bind(this);
this.handleRemoveAll = this.handleRemoveAll.bind(this);
}
handleSearch(key, value) {
const { location, qsConfig } = this.props;
const params = parseQueryString(qsConfig, location.search);
const qs = updateQueryString(qsConfig, location.search, {
...mergeParams(params, { [key]: value }), ...mergeParams(params, { [key]: value }),
page: 1, page: 1,
}); });
this.pushHistoryState(qs); pushHistoryState(qs);
} };
handleReplaceSearch(key, value) { const handleReplaceSearch = (key, value) => {
const { location, qsConfig } = this.props; const qs = updateQueryString(qsConfig, search, {
const qs = updateQueryString(qsConfig, location.search, {
[key]: value, [key]: value,
}); });
this.pushHistoryState(qs); pushHistoryState(qs);
} };
handleRemove(key, value) { const handleRemove = (key, value) => {
const { location, qsConfig } = this.props; const oldParams = parseQueryString(qsConfig, search);
const oldParams = parseQueryString(qsConfig, location.search);
const updatedParams = removeParams(qsConfig, oldParams, { const updatedParams = removeParams(qsConfig, oldParams, {
[key]: value, [key]: value,
}); });
const qs = updateQueryString(qsConfig, location.search, updatedParams); const qs = updateQueryString(qsConfig, search, updatedParams);
this.pushHistoryState(qs); pushHistoryState(qs);
} };
handleRemoveAll() { const handleRemoveAll = () => {
const { location, qsConfig } = this.props; const oldParams = parseQueryString(qsConfig, search);
const oldParams = parseQueryString(qsConfig, location.search);
Object.keys(oldParams).forEach((key) => { Object.keys(oldParams).forEach((key) => {
oldParams[key] = null; oldParams[key] = null;
}); });
delete oldParams.page_size; delete oldParams.page_size;
delete oldParams.order_by; delete oldParams.order_by;
const qs = updateQueryString(qsConfig, location.search, oldParams); const qs = updateQueryString(qsConfig, search, oldParams);
this.pushHistoryState(qs); pushHistoryState(qs);
} };
handleSort(key, order) { const handleSort = (key, order) => {
const { location, qsConfig } = this.props; const qs = updateQueryString(qsConfig, search, {
const qs = updateQueryString(qsConfig, location.search, {
order_by: order === 'ascending' ? key : `-${key}`, order_by: order === 'ascending' ? key : `-${key}`,
page: null, page: null,
}); });
this.pushHistoryState(qs); pushHistoryState(qs);
} };
pushHistoryState(queryString) { const pushHistoryState = (queryString) => {
const { history } = this.props;
const { pathname } = history.location;
history.push(queryString ? `${pathname}?${queryString}` : pathname); history.push(queryString ? `${pathname}?${queryString}` : pathname);
} };
render() { const params = parseQueryString(qsConfig, search);
const { const isEmpty = itemCount === 0 && Object.keys(params).length === 0;
emptyStateControls, return (
itemCount, <>
searchColumns, {isEmpty ? (
searchableKeys, <Toolbar
relatedSearchableKeys, id={`${qsConfig.namespace}-list-toolbar`}
sortColumns, clearAllFilters={handleRemoveAll}
renderToolbar, collapseListedFiltersBreakpoint="lg"
qsConfig, >
location, <ToolbarContent>
pagination, <EmptyStateControlsWrapper>
} = this.props; {emptyStateControls}
const params = parseQueryString(qsConfig, location.search); </EmptyStateControlsWrapper>
const isEmpty = itemCount === 0 && Object.keys(params).length === 0; </ToolbarContent>
return ( </Toolbar>
<> ) : (
{isEmpty ? ( <>
<Toolbar {renderToolbar({
id={`${qsConfig.namespace}-list-toolbar`} itemCount,
clearAllFilters={this.handleRemoveAll} searchColumns,
collapseListedFiltersBreakpoint="lg" sortColumns,
> searchableKeys,
<ToolbarContent> relatedSearchableKeys,
<EmptyStateControlsWrapper> onSearch: handleSearch,
{emptyStateControls} onReplaceSearch: handleReplaceSearch,
</EmptyStateControlsWrapper> onSort: handleSort,
</ToolbarContent> onRemove: handleRemove,
</Toolbar> clearAllFilters: handleRemoveAll,
) : ( qsConfig,
<> pagination,
{renderToolbar({ })}
itemCount, </>
searchColumns, )}
sortColumns, </>
searchableKeys, );
relatedSearchableKeys,
onSearch: this.handleSearch,
onReplaceSearch: this.handleReplaceSearch,
onSort: this.handleSort,
onRemove: this.handleRemove,
clearAllFilters: this.handleRemoveAll,
qsConfig,
pagination,
})}
</>
)}
</>
);
}
} }
ListHeader.propTypes = { ListHeader.propTypes = {
@@ -159,4 +142,4 @@ ListHeader.defaultProps = {
relatedSearchableKeys: [], relatedSearchableKeys: [],
}; };
export default withRouter(ListHeader); export default ListHeader;

View File

@@ -7,11 +7,15 @@ describe('ListHeader', () => {
const qsConfig = { const qsConfig = {
namespace: 'item', namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: 'foo' }, defaultParams: { page: 1, page_size: 5, order_by: 'foo' },
integerFields: [], integerFields: ['id', 'page', 'page_size'],
dateFields: ['modified', 'created'],
}; };
const renderToolbarFn = jest.fn(); const renderToolbarFn = jest.fn();
test('initially renders without crashing', () => { test('initially renders without crashing', () => {
const history = createMemoryHistory({
initialEntries: ['/organizations/1/teams'],
});
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<ListHeader <ListHeader
itemCount={50} itemCount={50}
@@ -21,7 +25,8 @@ describe('ListHeader', () => {
]} ]}
sortColumns={[{ name: 'foo', key: 'foo' }]} sortColumns={[{ name: 'foo', key: 'foo' }]}
renderToolbar={renderToolbarFn} renderToolbar={renderToolbarFn}
/> />,
{ context: { router: { history } } }
); );
expect(wrapper.length).toBe(1); expect(wrapper.length).toBe(1);
}); });

View File

@@ -137,14 +137,14 @@ function PaginatedTable({
return ( return (
<> <>
<ListHeader <ListHeader
itemCount={itemCount}
renderToolbar={renderToolbar}
emptyStateControls={emptyStateControls} emptyStateControls={emptyStateControls}
itemCount={itemCount}
pagination={ToolbarPagination}
qsConfig={qsConfig}
relatedSearchableKeys={toolbarRelatedSearchableKeys}
renderToolbar={renderToolbar}
searchColumns={searchColumns} searchColumns={searchColumns}
searchableKeys={toolbarSearchableKeys} searchableKeys={toolbarSearchableKeys}
relatedSearchableKeys={toolbarRelatedSearchableKeys}
qsConfig={qsConfig}
pagination={ToolbarPagination}
/> />
{Content} {Content}
{items.length ? ( {items.length ? (

View File

@@ -1,4 +1,4 @@
import React, { Component } from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Chip, Split as PFSplit, SplitItem } from '@patternfly/react-core'; import { Chip, Split as PFSplit, SplitItem } from '@patternfly/react-core';
@@ -16,42 +16,34 @@ const SplitLabelItem = styled(SplitItem)`
word-break: initial; word-break: initial;
`; `;
class SelectedList extends Component { function SelectedList(props) {
render() { const { label, selected, onRemove, displayKey, isReadOnly, renderItemChip } =
const { props;
label,
selected,
onRemove,
displayKey,
isReadOnly,
renderItemChip,
} = this.props;
const renderChip = const renderChip =
renderItemChip || renderItemChip ||
(({ item, removeItem }) => ( (({ item, removeItem }) => (
<Chip key={item.id} onClick={removeItem} isReadOnly={isReadOnly}> <Chip key={item.id} onClick={removeItem} isReadOnly={isReadOnly}>
{item[displayKey]} {item[displayKey]}
</Chip> </Chip>
)); ));
return ( return (
<Split> <Split>
<SplitLabelItem>{label}</SplitLabelItem> <SplitLabelItem>{label}</SplitLabelItem>
<SplitItem> <SplitItem>
<ChipGroup numChips={5} totalChips={selected.length}> <ChipGroup numChips={5} totalChips={selected.length}>
{selected.map((item) => {selected.map((item) =>
renderChip({ renderChip({
item, item,
removeItem: () => onRemove(item), removeItem: () => onRemove(item),
canDelete: !isReadOnly, canDelete: !isReadOnly,
}) })
)} )}
</ChipGroup> </ChipGroup>
</SplitItem> </SplitItem>
</Split> </Split>
); );
}
} }
SelectedList.propTypes = { SelectedList.propTypes = {

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React, { useState } from 'react';
import { withRouter } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { PageSection, Card } from '@patternfly/react-core'; import { PageSection, Card } from '@patternfly/react-core';
import { TeamsAPI } from 'api'; import { TeamsAPI } from 'api';
@@ -7,54 +7,48 @@ import { Config } from 'contexts/Config';
import { CardBody } from 'components/Card'; import { CardBody } from 'components/Card';
import TeamForm from '../shared/TeamForm'; import TeamForm from '../shared/TeamForm';
class TeamAdd extends React.Component { function TeamAdd() {
constructor(props) { const [submitError, setSubmitError] = useState(null);
super(props); const history = useHistory();
this.handleSubmit = this.handleSubmit.bind(this);
this.handleCancel = this.handleCancel.bind(this);
this.state = { error: null };
}
async handleSubmit(values) { const handleSubmit = async (values) => {
const { history } = this.props;
try { try {
const valuesToSend = { ...values }; const {
valuesToSend.organization = valuesToSend.organization.id; name,
description,
organization: { id },
} = values;
const valuesToSend = { name, description, organization: id };
const { data: response } = await TeamsAPI.create(valuesToSend); const { data: response } = await TeamsAPI.create(valuesToSend);
history.push(`/teams/${response.id}`); history.push(`/teams/${response.id}`);
} catch (error) { } catch (error) {
this.setState({ error }); setSubmitError(error);
} }
} };
handleCancel() { const handleCancel = () => {
const { history } = this.props;
history.push('/teams'); history.push('/teams');
} };
render() { return (
const { error } = this.state; <PageSection>
<Card>
return ( <CardBody>
<PageSection> <Config>
<Card> {({ me }) => (
<CardBody> <TeamForm
<Config> handleSubmit={handleSubmit}
{({ me }) => ( handleCancel={handleCancel}
<TeamForm me={me || {}}
handleSubmit={this.handleSubmit} submitError={submitError}
handleCancel={this.handleCancel} />
me={me || {}} )}
submitError={error} </Config>
/> </CardBody>
)} </Card>
</Config> </PageSection>
</CardBody> );
</Card>
</PageSection>
);
}
} }
export { TeamAdd as _TeamAdd }; export { TeamAdd as _TeamAdd };
export default withRouter(TeamAdd); export default TeamAdd;

View File

@@ -12,8 +12,7 @@ jest.mock('../../../api');
describe('<TeamAdd />', () => { describe('<TeamAdd />', () => {
test('handleSubmit should post to api', async () => { test('handleSubmit should post to api', async () => {
TeamsAPI.create.mockResolvedValueOnce({ data: {} }); const history = createMemoryHistory({});
const wrapper = mountWithContexts(<TeamAdd />);
const updatedTeamData = { const updatedTeamData = {
name: 'new name', name: 'new name',
description: 'new description', description: 'new description',
@@ -22,6 +21,10 @@ describe('<TeamAdd />', () => {
name: 'Default', name: 'Default',
}, },
}; };
TeamsAPI.create.mockResolvedValueOnce({ data: {} });
const wrapper = mountWithContexts(<TeamAdd />, {
context: { router: { history } },
});
await act(async () => { await act(async () => {
wrapper.find('TeamForm').invoke('handleSubmit')(updatedTeamData); wrapper.find('TeamForm').invoke('handleSubmit')(updatedTeamData);
}); });

View File

@@ -228,7 +228,7 @@ export function updateQueryString(config, queryString, newParams) {
return encodeQueryString(allParams); return encodeQueryString(allParams);
} }
function parseFullQueryString(queryString) { function parseFullQueryString(queryString = '') {
const allParams = {}; const allParams = {};
queryString queryString
.replace(/^\?/, '') .replace(/^\?/, '')