diff --git a/awx/ui/src/components/AddRole/CheckboxCard.js b/awx/ui/src/components/AddRole/CheckboxCard.js index 0b3f96fb7e..828df6a213 100644 --- a/awx/ui/src/components/AddRole/CheckboxCard.js +++ b/awx/ui/src/components/AddRole/CheckboxCard.js @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { Checkbox as PFCheckbox } from '@patternfly/react-core'; import styled from 'styled-components'; @@ -17,27 +17,25 @@ const Checkbox = styled(PFCheckbox)` } `; -class CheckboxCard extends Component { - render() { - const { name, description, isSelected, onSelect, itemId } = this.props; - return ( - - -
{name}
-
{description}
- - } - value={itemId} - /> -
- ); - } +function CheckboxCard(props) { + const { name, description, isSelected, onSelect, itemId } = props; + return ( + + +
{name}
+
{description}
+ + } + value={itemId} + /> +
+ ); } CheckboxCard.propTypes = { diff --git a/awx/ui/src/components/AppContainer/NavExpandableGroup.js b/awx/ui/src/components/AppContainer/NavExpandableGroup.js index 10a59fcd60..694545ec02 100644 --- a/awx/ui/src/components/AppContainer/NavExpandableGroup.js +++ b/awx/ui/src/components/AppContainer/NavExpandableGroup.js @@ -1,60 +1,45 @@ -import React, { Component } from 'react'; +import React from 'react'; 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'; -class NavExpandableGroup extends Component { - constructor(props) { - super(props); - const { routes } = this.props; +function NavExpandableGroup(props) { + const history = useHistory(); + const { groupId, groupTitle, routes } = props; - // 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. - this.navItemPaths = routes.map(({ path }) => path); - this.isActiveGroup = this.isActiveGroup.bind(this); - this.isActivePath = this.isActivePath.bind(this); - } + // 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. + const navItemPaths = routes.map(({ path }) => path); - isActiveGroup() { - return this.navItemPaths.some(this.isActivePath); - } + const isActive = navItemPaths.some(isActivePath); - isActivePath(path) { - const { history } = this.props; + function isActivePath(path) { return Boolean(matchPath(history.location.pathname, { path })); } - render() { - const { groupId, groupTitle, routes } = this.props; - - if (routes.length === 1 && groupId === 'settings') { - const [{ path }] = routes; - return ( - - {groupTitle} - - ); - } - + if (routes.length === 1 && groupId === 'settings') { + const [{ path }] = routes; return ( - - {routes.map(({ path, title }) => ( - - {title} - - ))} - + + {groupTitle} + ); } + + return ( + + {routes.map(({ path, title }) => ( + + {title} + + ))} + + ); } NavExpandableGroup.propTypes = { @@ -63,4 +48,4 @@ NavExpandableGroup.propTypes = { routes: arrayOf(PropTypes.object).isRequired, }; -export default withRouter(NavExpandableGroup); +export default NavExpandableGroup; diff --git a/awx/ui/src/components/AppContainer/NavExpandableGroup.test.js b/awx/ui/src/components/AppContainer/NavExpandableGroup.test.js index 486cda9e6d..90c0214ac6 100644 --- a/awx/ui/src/components/AppContainer/NavExpandableGroup.test.js +++ b/awx/ui/src/components/AppContainer/NavExpandableGroup.test.js @@ -1,9 +1,11 @@ import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, withRouter } from 'react-router-dom'; import { mount } from 'enzyme'; import { Nav } from '@patternfly/react-core'; -import NavExpandableGroup from './NavExpandableGroup'; +import _NavExpandableGroup from './NavExpandableGroup'; + +const NavExpandableGroup = withRouter(_NavExpandableGroup); describe('NavExpandableGroup', () => { test('initialization and render', () => { @@ -21,47 +23,88 @@ describe('NavExpandableGroup', () => { /> - ) - .find('NavExpandableGroup') - .instance(); + ).find('NavExpandableGroup'); - expect(component.navItemPaths).toEqual(['/foo', '/bar', '/fiz']); - expect(component.isActiveGroup()).toEqual(true); + expect(component.find('NavItem').length).toEqual(3); + 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', () => { - const params = [ - ['/fo', '/foo', false], - ['/foo', '/foo', true], - ['/foo/1/bar/fiz', '/foo', true], - ['/foo/1/bar/fiz', 'foo', false], - ['/foo/1/bar/fiz', 'foo/', false], - ['/foo/1/bar/fiz', '/bar', false], - ['/foo/1/bar/fiz', '/fiz', false], - ]; + test('when location is /foo/1/bar/fiz isActive returns false', () => { + const component = mount( + + + + ).find('NavExpandableGroup'); - params.forEach(([location, path, expected]) => { - test(`when location is ${location}, isActivePath('${path}') returns ${expected} `, () => { - const component = mount( - - - - ) - .find('NavExpandableGroup') - .instance(); + 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'); + }); - expect(component.isActivePath(path)).toEqual(expected); - }); - }); + test('when location is /fo isActive returns false', () => { + const component = mount( + + + + ).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( + + + + ).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'); }); }); diff --git a/awx/ui/src/components/AppendBody/AppendBody.js b/awx/ui/src/components/AppendBody/AppendBody.js index 62e2551372..f7317187b1 100644 --- a/awx/ui/src/components/AppendBody/AppendBody.js +++ b/awx/ui/src/components/AppendBody/AppendBody.js @@ -1,24 +1,17 @@ -import { Component } from 'react'; +import { useEffect } from 'react'; import ReactDOM from 'react-dom'; -class AppendBody extends Component { - constructor(props) { - super(props); - this.el = document.createElement('div'); - } +function AppendBody({ children }) { + const el = document.createElement('div'); - componentDidMount() { - document.body.appendChild(this.el); - } + useEffect(() => { + document.body.appendChild(el); + return () => { + document.body.removeChild(el); + }; + }, [el]); - componentWillUnmount() { - document.body.removeChild(this.el); - } - - render() { - const { children } = this.props; - return ReactDOM.createPortal(children, this.el); - } + return ReactDOM.createPortal(children, el); } export default AppendBody; diff --git a/awx/ui/src/components/ListHeader/ListHeader.js b/awx/ui/src/components/ListHeader/ListHeader.js index 27055f1c45..d44103803e 100644 --- a/awx/ui/src/components/ListHeader/ListHeader.js +++ b/awx/ui/src/components/ListHeader/ListHeader.js @@ -1,6 +1,6 @@ -import React, { Fragment } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import { withRouter } from 'react-router-dom'; +import { useHistory, useLocation } from 'react-router-dom'; import styled from 'styled-components'; import { Toolbar, ToolbarContent } from '@patternfly/react-core'; @@ -24,122 +24,105 @@ const EmptyStateControlsWrapper = styled.div` margin-left: 20px; } `; -class ListHeader extends React.Component { - constructor(props) { - super(props); +function ListHeader(props) { + const { search, pathname } = useLocation(); + const history = useHistory(); + const { + emptyStateControls, + itemCount, + pagination, + qsConfig, + relatedSearchableKeys, + renderToolbar, + searchColumns, + searchableKeys, + sortColumns, + } = props; - this.handleSearch = this.handleSearch.bind(this); - this.handleReplaceSearch = this.handleReplaceSearch.bind(this); - this.handleSort = this.handleSort.bind(this); - 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, { + const handleSearch = (key, value) => { + const params = parseQueryString(qsConfig, search); + const qs = updateQueryString(qsConfig, search, { ...mergeParams(params, { [key]: value }), page: 1, }); - this.pushHistoryState(qs); - } + pushHistoryState(qs); + }; - handleReplaceSearch(key, value) { - const { location, qsConfig } = this.props; - const qs = updateQueryString(qsConfig, location.search, { + const handleReplaceSearch = (key, value) => { + const qs = updateQueryString(qsConfig, search, { [key]: value, }); - this.pushHistoryState(qs); - } + pushHistoryState(qs); + }; - handleRemove(key, value) { - const { location, qsConfig } = this.props; - const oldParams = parseQueryString(qsConfig, location.search); + const handleRemove = (key, value) => { + const oldParams = parseQueryString(qsConfig, search); const updatedParams = removeParams(qsConfig, oldParams, { [key]: value, }); - const qs = updateQueryString(qsConfig, location.search, updatedParams); - this.pushHistoryState(qs); - } + const qs = updateQueryString(qsConfig, search, updatedParams); + pushHistoryState(qs); + }; - handleRemoveAll() { - const { location, qsConfig } = this.props; - const oldParams = parseQueryString(qsConfig, location.search); + const handleRemoveAll = () => { + const oldParams = parseQueryString(qsConfig, search); Object.keys(oldParams).forEach((key) => { oldParams[key] = null; }); delete oldParams.page_size; delete oldParams.order_by; - const qs = updateQueryString(qsConfig, location.search, oldParams); - this.pushHistoryState(qs); - } + const qs = updateQueryString(qsConfig, search, oldParams); + pushHistoryState(qs); + }; - handleSort(key, order) { - const { location, qsConfig } = this.props; - const qs = updateQueryString(qsConfig, location.search, { + const handleSort = (key, order) => { + const qs = updateQueryString(qsConfig, search, { order_by: order === 'ascending' ? key : `-${key}`, page: null, }); - this.pushHistoryState(qs); - } + pushHistoryState(qs); + }; - pushHistoryState(queryString) { - const { history } = this.props; - const { pathname } = history.location; + const pushHistoryState = (queryString) => { history.push(queryString ? `${pathname}?${queryString}` : pathname); - } + }; - render() { - const { - emptyStateControls, - itemCount, - searchColumns, - searchableKeys, - relatedSearchableKeys, - sortColumns, - renderToolbar, - qsConfig, - location, - pagination, - } = this.props; - const params = parseQueryString(qsConfig, location.search); - const isEmpty = itemCount === 0 && Object.keys(params).length === 0; - return ( - <> - {isEmpty ? ( - - - - {emptyStateControls} - - - - ) : ( - <> - {renderToolbar({ - itemCount, - searchColumns, - sortColumns, - searchableKeys, - relatedSearchableKeys, - onSearch: this.handleSearch, - onReplaceSearch: this.handleReplaceSearch, - onSort: this.handleSort, - onRemove: this.handleRemove, - clearAllFilters: this.handleRemoveAll, - qsConfig, - pagination, - })} - - )} - - ); - } + const params = parseQueryString(qsConfig, search); + const isEmpty = itemCount === 0 && Object.keys(params).length === 0; + return ( + <> + {isEmpty ? ( + + + + {emptyStateControls} + + + + ) : ( + <> + {renderToolbar({ + itemCount, + searchColumns, + sortColumns, + searchableKeys, + relatedSearchableKeys, + onSearch: handleSearch, + onReplaceSearch: handleReplaceSearch, + onSort: handleSort, + onRemove: handleRemove, + clearAllFilters: handleRemoveAll, + qsConfig, + pagination, + })} + + )} + + ); } ListHeader.propTypes = { @@ -159,4 +142,4 @@ ListHeader.defaultProps = { relatedSearchableKeys: [], }; -export default withRouter(ListHeader); +export default ListHeader; diff --git a/awx/ui/src/components/ListHeader/ListHeader.test.js b/awx/ui/src/components/ListHeader/ListHeader.test.js index 6cca8a7969..60e304110a 100644 --- a/awx/ui/src/components/ListHeader/ListHeader.test.js +++ b/awx/ui/src/components/ListHeader/ListHeader.test.js @@ -7,11 +7,15 @@ describe('ListHeader', () => { const qsConfig = { namespace: 'item', defaultParams: { page: 1, page_size: 5, order_by: 'foo' }, - integerFields: [], + integerFields: ['id', 'page', 'page_size'], + dateFields: ['modified', 'created'], }; const renderToolbarFn = jest.fn(); test('initially renders without crashing', () => { + const history = createMemoryHistory({ + initialEntries: ['/organizations/1/teams'], + }); const wrapper = mountWithContexts( { ]} sortColumns={[{ name: 'foo', key: 'foo' }]} renderToolbar={renderToolbarFn} - /> + />, + { context: { router: { history } } } ); expect(wrapper.length).toBe(1); }); diff --git a/awx/ui/src/components/PaginatedTable/PaginatedTable.js b/awx/ui/src/components/PaginatedTable/PaginatedTable.js index 37ac22c4a9..ee1d379014 100644 --- a/awx/ui/src/components/PaginatedTable/PaginatedTable.js +++ b/awx/ui/src/components/PaginatedTable/PaginatedTable.js @@ -137,14 +137,14 @@ function PaginatedTable({ return ( <> {Content} {items.length ? ( diff --git a/awx/ui/src/components/SelectedList/SelectedList.js b/awx/ui/src/components/SelectedList/SelectedList.js index c386ffe54c..07846cd552 100644 --- a/awx/ui/src/components/SelectedList/SelectedList.js +++ b/awx/ui/src/components/SelectedList/SelectedList.js @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { Chip, Split as PFSplit, SplitItem } from '@patternfly/react-core'; @@ -16,42 +16,34 @@ const SplitLabelItem = styled(SplitItem)` word-break: initial; `; -class SelectedList extends Component { - render() { - const { - label, - selected, - onRemove, - displayKey, - isReadOnly, - renderItemChip, - } = this.props; +function SelectedList(props) { + const { label, selected, onRemove, displayKey, isReadOnly, renderItemChip } = + props; - const renderChip = - renderItemChip || - (({ item, removeItem }) => ( - - {item[displayKey]} - - )); + const renderChip = + renderItemChip || + (({ item, removeItem }) => ( + + {item[displayKey]} + + )); - return ( - - {label} - - - {selected.map((item) => - renderChip({ - item, - removeItem: () => onRemove(item), - canDelete: !isReadOnly, - }) - )} - - - - ); - } + return ( + + {label} + + + {selected.map((item) => + renderChip({ + item, + removeItem: () => onRemove(item), + canDelete: !isReadOnly, + }) + )} + + + + ); } SelectedList.propTypes = { diff --git a/awx/ui/src/screens/Team/TeamAdd/TeamAdd.js b/awx/ui/src/screens/Team/TeamAdd/TeamAdd.js index bc23d337e3..dd5d979ff1 100644 --- a/awx/ui/src/screens/Team/TeamAdd/TeamAdd.js +++ b/awx/ui/src/screens/Team/TeamAdd/TeamAdd.js @@ -1,5 +1,5 @@ -import React from 'react'; -import { withRouter } from 'react-router-dom'; +import React, { useState } from 'react'; +import { useHistory } from 'react-router-dom'; import { PageSection, Card } from '@patternfly/react-core'; import { TeamsAPI } from 'api'; @@ -7,54 +7,48 @@ import { Config } from 'contexts/Config'; import { CardBody } from 'components/Card'; import TeamForm from '../shared/TeamForm'; -class TeamAdd extends React.Component { - constructor(props) { - super(props); - this.handleSubmit = this.handleSubmit.bind(this); - this.handleCancel = this.handleCancel.bind(this); - this.state = { error: null }; - } +function TeamAdd() { + const [submitError, setSubmitError] = useState(null); + const history = useHistory(); - async handleSubmit(values) { - const { history } = this.props; + const handleSubmit = async (values) => { try { - const valuesToSend = { ...values }; - valuesToSend.organization = valuesToSend.organization.id; + const { + name, + description, + organization: { id }, + } = values; + const valuesToSend = { name, description, organization: id }; const { data: response } = await TeamsAPI.create(valuesToSend); history.push(`/teams/${response.id}`); } catch (error) { - this.setState({ error }); + setSubmitError(error); } - } + }; - handleCancel() { - const { history } = this.props; + const handleCancel = () => { history.push('/teams'); - } + }; - render() { - const { error } = this.state; - - return ( - - - - - {({ me }) => ( - - )} - - - - - ); - } + return ( + + + + + {({ me }) => ( + + )} + + + + + ); } export { TeamAdd as _TeamAdd }; -export default withRouter(TeamAdd); +export default TeamAdd; diff --git a/awx/ui/src/screens/Team/TeamAdd/TeamAdd.test.js b/awx/ui/src/screens/Team/TeamAdd/TeamAdd.test.js index c18c55e927..8a7a193d5c 100644 --- a/awx/ui/src/screens/Team/TeamAdd/TeamAdd.test.js +++ b/awx/ui/src/screens/Team/TeamAdd/TeamAdd.test.js @@ -12,8 +12,7 @@ jest.mock('../../../api'); describe('', () => { test('handleSubmit should post to api', async () => { - TeamsAPI.create.mockResolvedValueOnce({ data: {} }); - const wrapper = mountWithContexts(); + const history = createMemoryHistory({}); const updatedTeamData = { name: 'new name', description: 'new description', @@ -22,6 +21,10 @@ describe('', () => { name: 'Default', }, }; + TeamsAPI.create.mockResolvedValueOnce({ data: {} }); + const wrapper = mountWithContexts(, { + context: { router: { history } }, + }); await act(async () => { wrapper.find('TeamForm').invoke('handleSubmit')(updatedTeamData); }); diff --git a/awx/ui/src/util/qs.js b/awx/ui/src/util/qs.js index 1ef09d4be3..6f63a6372d 100644 --- a/awx/ui/src/util/qs.js +++ b/awx/ui/src/util/qs.js @@ -228,7 +228,7 @@ export function updateQueryString(config, queryString, newParams) { return encodeQueryString(allParams); } -function parseFullQueryString(queryString) { +function parseFullQueryString(queryString = '') { const allParams = {}; queryString .replace(/^\?/, '')