diff --git a/__tests__/App.test.jsx b/__tests__/App.test.jsx index 420d6832ab..0a1870eca0 100644 --- a/__tests__/App.test.jsx +++ b/__tests__/App.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { HashRouter } from 'react-router-dom'; +import { MemoryRouter } from 'react-router-dom'; import { I18nProvider } from '@lingui/react'; import { mount, shallow } from 'enzyme'; @@ -12,7 +12,7 @@ const DEFAULT_ACTIVE_GROUP = 'views_group'; describe('', () => { test('expected content is rendered', () => { const appWrapper = mount( - + ', () => { )} /> - + ); // page components @@ -56,6 +56,48 @@ describe('', () => { expect(appWrapper.find('#group_two').length).toBe(1); }); + test('opening the about modal renders prefetched config data', async (done) => { + const ansible_version = '111'; + const version = '222'; + + const getConfig = jest.fn(() => Promise.resolve({ data: { ansible_version, version} })); + const api = { getConfig }; + + const wrapper = mount( + + + + + + ); + + await asyncFlush(); + expect(getConfig).toHaveBeenCalledTimes(1); + + // open about modal + const aboutDropdown = 'Dropdown QuestionCircleIcon'; + const aboutButton = 'DropdownItem li button'; + const aboutModalContent = 'AboutModalBoxContent'; + const aboutModalClose = 'button[aria-label="Close Dialog"]'; + + expect(wrapper.find(aboutModalContent)).toHaveLength(0); + wrapper.find(aboutDropdown).simulate('click'); + wrapper.find(aboutButton).simulate('click'); + wrapper.update(); + + // check about modal content + const content = wrapper.find(aboutModalContent); + expect(content).toHaveLength(1); + expect(content.find('dd').text()).toContain(ansible_version); + expect(content.find('pre').text()).toContain(`< Tower ${version} >`); + + // close about modal + wrapper.find(aboutModalClose).simulate('click'); + expect(wrapper.find(aboutModalContent)).toHaveLength(0); + + done(); + }); + test('onNavToggle sets state.isNavOpen to opposite', () => { const appWrapper = shallow(); const { onNavToggle } = appWrapper.instance(); @@ -66,17 +108,6 @@ describe('', () => { }); }); - test('onLogoClick sets selected nav back to defaults', () => { - const appWrapper = shallow(); - - appWrapper.setState({ activeGroup: 'foo', activeItem: 'bar' }); - expect(appWrapper.state().activeItem).toBe('bar'); - expect(appWrapper.state().activeGroup).toBe('foo'); - - appWrapper.instance().onLogoClick(); - expect(appWrapper.state().activeGroup).toBe(DEFAULT_ACTIVE_GROUP); - }); - test('onLogout makes expected call to api client', async (done) => { const logout = jest.fn(() => Promise.resolve()); const api = { logout }; @@ -89,17 +120,4 @@ describe('', () => { done(); }); - - test('Component makes expected call to api client when mounted', () => { - const getConfig = jest.fn().mockImplementation(() => Promise.resolve({})); - const api = { getConfig }; - const appWrapper = mount( - - - - - - ); - expect(getConfig).toHaveBeenCalledTimes(1); - }); }); diff --git a/__tests__/api.test.js b/__tests__/api.test.js index c4a3ba3542..e9276e8c8a 100644 --- a/__tests__/api.test.js +++ b/__tests__/api.test.js @@ -58,4 +58,72 @@ describe('APIClient (api.js)', () => { done(); }); + + test('logout calls expected http method', async (done) => { + const createPromise = () => Promise.resolve(); + const mockHttp = ({ get: jest.fn(createPromise) }); + + const api = new APIClient(mockHttp); + await api.logout(); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + + done(); + }); + + test('getConfig calls expected http method', async (done) => { + const createPromise = () => Promise.resolve(); + const mockHttp = ({ get: jest.fn(createPromise) }); + + const api = new APIClient(mockHttp); + await api.getConfig(); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + + done(); + }); + + test('getOrganizations calls http method with expected data', async (done) => { + const createPromise = () => Promise.resolve(); + const mockHttp = ({ get: jest.fn(createPromise) }); + const api = new APIClient(mockHttp); + + const defaultParams = {}; + const testParams = { foo: 'bar' }; + + await api.getOrganizations(testParams); + await api.getOrganizations(); + + expect(mockHttp.get).toHaveBeenCalledTimes(2); + expect(mockHttp.get.mock.calls[0][1]).toEqual({ params: testParams }); + expect(mockHttp.get.mock.calls[1][1]).toEqual({ params: defaultParams }); + done(); + }); + + test('createOrganization calls http method with expected data', async (done) => { + const createPromise = () => Promise.resolve(); + const mockHttp = ({ post: jest.fn(createPromise) }); + + const api = new APIClient(mockHttp); + const data = { name: 'test '}; + await api.createOrganization(data); + + expect(mockHttp.post).toHaveBeenCalledTimes(1); + expect(mockHttp.post.mock.calls[0][1]).toEqual(data); + + done(); + }); + + test('getOrganizationDetails calls http method with expected data', async (done) => { + const createPromise = () => Promise.resolve(); + const mockHttp = ({ get: jest.fn(createPromise) }); + + const api = new APIClient(mockHttp); + await api.getOrganizationDetails(99); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + expect(mockHttp.get.mock.calls[0][0]).toContain('99'); + + done(); + }); }); diff --git a/__tests__/components/AnsibleSelect.test.jsx b/__tests__/components/AnsibleSelect.test.jsx index d2eacf2129..5a225b804d 100644 --- a/__tests__/components/AnsibleSelect.test.jsx +++ b/__tests__/components/AnsibleSelect.test.jsx @@ -29,4 +29,16 @@ describe('', () => { wrapper.find('select').simulate('change'); expect(spy).toHaveBeenCalled(); }); -}); \ No newline at end of file + test('content not rendered when data property is falsey', () => { + const wrapper = mount( + { }} + labelName={label} + data={null} + /> + ); + expect(wrapper.find('FormGroup')).toHaveLength(0); + expect(wrapper.find('Select')).toHaveLength(0); + }); +}); diff --git a/__tests__/components/DataListToolbar.test.jsx b/__tests__/components/DataListToolbar.test.jsx index 1c1a3d8b04..facc3b1128 100644 --- a/__tests__/components/DataListToolbar.test.jsx +++ b/__tests__/components/DataListToolbar.test.jsx @@ -29,6 +29,7 @@ describe('', () => { ', () => { test('submit a new page by typing in input works', () => { const textInputSelector = '.pf-l-split__item.pf-m-main .pf-c-form-control'; const submitFormSelector = '.pf-l-split__item.pf-m-main form'; - const onSetPage = jest.fn(); pagination = mount( @@ -137,6 +136,7 @@ describe('', () => { /> ); + const textInput = pagination.find(textInputSelector); expect(textInput.length).toBe(1); textInput.simulate('change'); @@ -145,7 +145,7 @@ describe('', () => { const submitForm = pagination.find(submitFormSelector); expect(submitForm.length).toBe(1); submitForm.simulate('submit'); - pagination.setState({ value: 'invalid' }); + pagination.find('Pagination').instance().setState({ value: 'invalid' }); submitForm.simulate('submit'); }); diff --git a/__tests__/components/TowerLogo.test.jsx b/__tests__/components/TowerLogo.test.jsx index 10e3461443..28151624f3 100644 --- a/__tests__/components/TowerLogo.test.jsx +++ b/__tests__/components/TowerLogo.test.jsx @@ -29,11 +29,10 @@ describe('', () => { }); test('adds navigation to route history on click', () => { - const onLogoClick = jest.fn(); logoWrapper = mount( - + ); @@ -43,12 +42,26 @@ describe('', () => { expect(towerLogoElem.props().history.length).toBe(2); }); + test('linkTo prop is optional', () => { + logoWrapper = mount( + + + + + + ); + findChildren(); + expect(towerLogoElem.props().history.length).toBe(1); + logoWrapper.simulate('click'); + expect(towerLogoElem.props().history.length).toBe(1); + }); + test('handles mouse over and out state.hover changes', () => { const onLogoClick = jest.fn(); logoWrapper = mount( - + ); diff --git a/__tests__/index.test.jsx b/__tests__/index.test.jsx index d6789513dc..83490ec5ce 100644 --- a/__tests__/index.test.jsx +++ b/__tests__/index.test.jsx @@ -1,11 +1,11 @@ import { mount } from 'enzyme'; -import { main } from '../src/index'; +import { main, getLanguage } from '../src/index'; const render = template => mount(template); const data = { custom_logo: 'foo', custom_login_info: '' } describe('index.jsx', () => { - test('initialization', async (done) => { + test('login loads when unauthenticated', async (done) => { const isAuthenticated = () => false; const getRoot = jest.fn(() => Promise.resolve({ data })); @@ -13,7 +13,7 @@ describe('index.jsx', () => { const wrapper = await main(render, api); expect(api.getRoot).toHaveBeenCalled(); - expect(wrapper.find('Dashboard')).toHaveLength(0); + expect(wrapper.find('App')).toHaveLength(0); expect(wrapper.find('Login')).toHaveLength(1); const { src } = wrapper.find('Login Brand img').props(); @@ -22,7 +22,7 @@ describe('index.jsx', () => { done(); }); - test('dashboard is loaded when authenticated', async (done) => { + test('app loads when authenticated', async (done) => { const isAuthenticated = () => true; const getRoot = jest.fn(() => Promise.resolve({ data })); @@ -30,9 +30,22 @@ describe('index.jsx', () => { const wrapper = await main(render, api); expect(api.getRoot).toHaveBeenCalled(); - expect(wrapper.find('Dashboard')).toHaveLength(1); + expect(wrapper.find('App')).toHaveLength(1); + expect(wrapper.find('Login')).toHaveLength(0); + + wrapper.find('header a').simulate('click'); + wrapper.update(); + + expect(wrapper.find('App')).toHaveLength(1); expect(wrapper.find('Login')).toHaveLength(0); done(); }); + + test('getLanguage returns the expected language code', () => { + expect(getLanguage({ languages: ['es-US'] })).toEqual('es'); + expect(getLanguage({ languages: ['es-US'], language: 'fr-FR', userLanguage: 'en-US' })).toEqual('es'); + expect(getLanguage({ language: 'fr-FR', userLanguage: 'en-US' })).toEqual('fr'); + expect(getLanguage({ userLanguage: 'en-US' })).toEqual('en'); + }); }); diff --git a/src/App.jsx b/src/App.jsx index 93003c0c8d..e8de658220 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -28,14 +28,12 @@ class App extends Component { isAboutModalOpen: false, isNavOpen, version: null, - }; this.fetchConfig = this.fetchConfig.bind(this); this.onLogout = this.onLogout.bind(this); this.onAboutModalClose = this.onAboutModalClose.bind(this); this.onAboutModalOpen = this.onAboutModalOpen.bind(this); - this.onLogoClick = this.onLogoClick.bind(this); this.onNavToggle = this.onNavToggle.bind(this); }; @@ -73,10 +71,6 @@ class App extends Component { this.setState(({ isNavOpen }) => ({ isNavOpen: !isNavOpen })); } - onLogoClick () { - this.setState({ activeGroup: 'views_group' }); - } - render () { const { ansible_version, @@ -106,11 +100,7 @@ class App extends Component { - } + logo={} toolbar={ name === target.innerText); + const [{ key: searchKey }] = columns.filter(({ name }) => name === innerText); this.setState({ isSortDropdownOpen: false }); - - onSort(key, sortOrder); + onSort(searchKey, sortOrder); } onSearchDropdownToggle (isSearchDropdownOpen) { @@ -76,11 +77,10 @@ class DataListToolbar extends React.Component { onSearchDropdownSelect ({ target }) { const { columns } = this.props; + const { innerText } = target; - const targetName = target.innerText; - const [{ key }] = columns.filter(({ name }) => name === targetName); - - this.setState({ isSearchDropdownOpen: false, searchKey: key }); + const [{ key: searchKey }] = columns.filter(({ name }) => name === innerText); + this.setState({ isSearchDropdownOpen: false, searchKey }); } onSearch () { @@ -90,13 +90,19 @@ class DataListToolbar extends React.Component { onSearch(searchValue); } + onSort () { + const { onSort, sortedColumnKey, sortOrder } = this.props; + const newSortOrder = sortOrder === 'ascending' ? 'descending' : 'ascending'; + + onSort(sortedColumnKey, newSortOrder); + } + render () { const { up } = DropdownPosition; const { columns, isAllSelected, onSelectAll, - onSort, sortedColumnKey, sortOrder, addUrl, @@ -110,29 +116,15 @@ class DataListToolbar extends React.Component { searchValue, } = this.state; - const [searchColumn] = columns - .filter(({ key }) => key === searchKey); - const searchColumnName = searchColumn.name; - - const [sortedColumn] = columns + const [{ name: searchColumnName }] = columns.filter(({ key }) => key === searchKey); + const [{ name: sortedColumnName, isNumeric }] = columns .filter(({ key }) => key === sortedColumnKey); - const sortedColumnName = sortedColumn.name; - const isSortNumeric = sortedColumn.isNumeric; - const displayedSortIcon = () => { - let icon; - if (sortOrder === 'ascending') { - icon = isSortNumeric ? () : (); - } else { - icon = isSortNumeric ? () : (); - } - return icon; - }; const searchDropdownItems = columns .filter(({ key }) => key !== searchKey) .map(({ key, name }) => ( - { name } + {name} )); @@ -140,17 +132,26 @@ class DataListToolbar extends React.Component { .filter(({ key, isSortable }) => isSortable && key !== sortedColumnKey) .map(({ key, name }) => ( - { name } + {name} )); + let SortIcon; + if (isNumeric) { + SortIcon = sortOrder === 'ascending' ? SortNumericUpIcon : SortNumericDownIcon; + } else { + SortIcon = sortOrder === 'ascending' ? SortAlphaUpIcon : SortAlphaDownIcon; + } + return ( {({ i18n }) => (
- + - { searchColumnName } + {searchColumnName} )} dropdownItems={searchDropdownItems} @@ -195,7 +196,9 @@ class DataListToolbar extends React.Component {
- + - { sortedColumnName } + {sortedColumnName} )} dropdownItems={sortDropdownItems} @@ -214,23 +217,29 @@ class DataListToolbar extends React.Component { - { showExpandCollapse && ( + {showExpandCollapse && ( - - @@ -239,14 +248,23 @@ class DataListToolbar extends React.Component { - - {addUrl && ( - diff --git a/src/components/Pagination/Pagination.jsx b/src/components/Pagination/Pagination.jsx index 98998b4140..f7a6e4d461 100644 --- a/src/components/Pagination/Pagination.jsx +++ b/src/components/Pagination/Pagination.jsx @@ -18,8 +18,7 @@ class Pagination extends Component { constructor (props) { super(props); - const { page } = this.props; - + const { page } = props; this.state = { value: page, isOpen: false }; this.onPageChange = this.onPageChange.bind(this); @@ -70,18 +69,14 @@ class Pagination extends Component { const { onSetPage, page, page_size } = this.props; const previousPage = page - 1; - if (previousPage >= 1) { - onSetPage(previousPage, page_size); - } + onSetPage(previousPage, page_size); } onNext () { const { onSetPage, page, pageCount, page_size } = this.props; const nextPage = page + 1; - if (nextPage <= pageCount) { - onSetPage(nextPage, page_size); - } + onSetPage(nextPage, page_size); } onLast () { @@ -143,14 +138,20 @@ class Pagination extends Component { direction={up} isOpen={isOpen} toggle={( - - { page_size } + + {page_size} )} > {opts.map(option => ( - - { option } + + {option} ))} @@ -159,7 +160,7 @@ class Pagination extends Component { - { itemMin } - { itemMax } of { count } + {itemMin} - {itemMax} of {count}
@@ -200,7 +201,7 @@ class Pagination extends Component { value={value} type="text" onChange={this.onPageChange} - /> of { pageCount } + /> of {pageCount} diff --git a/src/components/TowerLogo/TowerLogo.jsx b/src/components/TowerLogo/TowerLogo.jsx index 10925a3046..402adbaa0e 100644 --- a/src/components/TowerLogo/TowerLogo.jsx +++ b/src/components/TowerLogo/TowerLogo.jsx @@ -18,13 +18,11 @@ class TowerLogo extends Component { } onClick () { - const { history, onClick: handleClick } = this.props; + const { history, linkTo } = this.props; - if (!handleClick) return; + if (!linkTo) return; - history.push('/'); - - handleClick(); + history.push(linkTo); } onHover () { @@ -35,11 +33,10 @@ class TowerLogo extends Component { render () { const { hover } = this.state; - const { onClick: handleClick } = this.props; let src = TowerLogoHeader; - if (hover && handleClick) { + if (hover) { src = TowerLogoHeaderHover; } diff --git a/src/index.jsx b/src/index.jsx index 8f450e9c4a..dfd6b421d9 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -60,21 +60,25 @@ const http = axios.create({ xsrfCookieName: 'csrftoken', xsrfHeaderName: 'X-CSRF // see: https://developer.mozilla.org/en-US/docs/Web/API/Navigator // -const language = (navigator.languages && navigator.languages[0]) - || navigator.language - || navigator.userLanguage; -const languageWithoutRegionCode = language.toLowerCase().split(/[_-]+/)[0]; -const catalogs = { en, ja }; +export function getLanguage (nav) { + const language = (nav.languages && nav.languages[0]) || nav.language || nav.userLanguage; + const languageWithoutRegionCode = language.toLowerCase().split(/[_-]+/)[0]; + + return languageWithoutRegionCode; +}; // // Function Main // export async function main (render, api) { + const catalogs = { en, ja }; + const language = getLanguage(navigator); + const el = document.getElementById('app'); - // fetch additional config from server const { data: { custom_logo, custom_login_info } } = await api.getRoot(); + const defaultRedirect = () => (); const loginRoutes = ( @@ -102,8 +106,8 @@ export async function main (render, api) { {!api.isAuthenticated() ? loginRoutes : ( - ()} /> - ()} /> + + ( 1) { - // Show dropdown if we have more than one ansible environment - this.setState({ hideAnsibleSelect: !this.state.hideAnsibleSelect }); - } - } catch (error) { - this.setState({ error }) - } - } - render() { const { name } = this.state; const enabled = name.length > 0; // TODO: add better form validation + return ( @@ -128,13 +115,16 @@ class OrganizationAdd extends React.Component { onChange={this.handleChange} /> -