diff --git a/src/Components/BidderPortfolio/BidControls/BidControls.jsx b/src/Components/BidderPortfolio/BidControls/BidControls.jsx index a9cd01a3e2..cf42fa25cd 100644 --- a/src/Components/BidderPortfolio/BidControls/BidControls.jsx +++ b/src/Components/BidderPortfolio/BidControls/BidControls.jsx @@ -4,6 +4,7 @@ import TotalResults from './TotalResults'; import SelectForm from '../../SelectForm'; import { BID_PORTFOLIO_SORTS } from '../../../Constants/Sort'; import ResultsViewBy from '../../ResultsViewBy/ResultsViewBy'; +import ExportLink from '../ExportLink'; class BidControls extends Component { constructor(props) { @@ -36,6 +37,7 @@ class BidControls extends Component { onSelectOption={this.onSortChange} /> + diff --git a/src/Components/BidderPortfolio/BidControls/__snapshots__/BidControls.test.jsx.snap b/src/Components/BidderPortfolio/BidControls/__snapshots__/BidControls.test.jsx.snap index 34f7fa7f08..d692f56a93 100644 --- a/src/Components/BidderPortfolio/BidControls/__snapshots__/BidControls.test.jsx.snap +++ b/src/Components/BidderPortfolio/BidControls/__snapshots__/BidControls.test.jsx.snap @@ -49,6 +49,7 @@ exports[`BidControlsComponent matches snapshot when isLoading is false 1`] = ` initial="card" onClick={[Function]} /> + @@ -103,6 +104,7 @@ exports[`BidControlsComponent matches snapshot when isLoading is true 1`] = ` initial="card" onClick={[Function]} /> + diff --git a/src/Components/BidderPortfolio/ExportLink/ExportLink.jsx b/src/Components/BidderPortfolio/ExportLink/ExportLink.jsx new file mode 100644 index 0000000000..d187c36003 --- /dev/null +++ b/src/Components/BidderPortfolio/ExportLink/ExportLink.jsx @@ -0,0 +1,105 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { CSVLink } from 'react-csv'; +import { get } from 'lodash'; +import { bidderPortfolioFetchDataFromLastQuery } from '../../../actions/bidderPortfolio'; +import { EMPTY_FUNCTION } from '../../../Constants/PropTypes'; + +// Mapping columns to data fields +const HEADERS = [ + { label: 'Last Name', key: 'user.last_name' }, + { label: 'First Name', key: 'user.first_name' }, + { label: 'Email', key: 'user.email' }, + { label: 'Username', key: 'user.username' }, + { label: 'Grade', key: 'grade' }, + { label: 'Primary Nationality', key: 'primary_nationality' }, + { label: 'Secondary Nationality', key: 'secondary_nationality' }, + { label: 'Current Assignment', key: 'current_assignment' }, + { label: 'Language', key: 'language_qualifications[0].representation' }, +]; + +// Processes results before sending to the download component to allow for custom formatting. +const processData = data => ( + data.map(entry => ({ + ...entry, + // any other processing we may want to do here + })) +); + +export class ExportLink extends Component { + constructor(props) { + super(props); + this.onClick = this.onClick.bind(this); + this.state = { + data: '', + isLoading: false, + }; + } + + componentWillReceiveProps(nextProps) { + if (!nextProps.isLoading) { + this.setState({ isLoading: false }); + } + if (this.props.isLoading && !nextProps.isLoading && !nextProps.hasErrored) { + const data = processData(nextProps.data.results); + this.setState({ data, isLoading: false }, () => { + if (get(this.csvLink, 'link.click')) { + this.csvLink.link.click(); + } + }); + } + } + + onClick() { + const { isLoading } = this.state; + const { fetchData } = this.props; + if (!isLoading) { + this.setState({ + isLoading: true, + }, () => { + fetchData(); + }); + } + } + + render() { + const { data, isLoading } = this.state; + return ( +
+ + { this.csvLink = x; }} target="_blank" filename={this.props.filename} data={data} headers={HEADERS} /> +
+ ); + } +} + +ExportLink.propTypes = { + filename: PropTypes.string, + hasErrored: PropTypes.bool, + isLoading: PropTypes.bool, + data: PropTypes.shape({ results: PropTypes.arrayOf(PropTypes.shape({})) }), + fetchData: PropTypes.func, +}; + +ExportLink.defaultProps = { + filename: 'TalentMap_bidder_portfolio_export.csv', + hasErrored: false, + isLoading: false, + data: {}, + fetchData: EMPTY_FUNCTION, +}; + +const mapStateToProps = state => ({ + hasErrored: state.lastBidderPortfolioHasErrored, + isLoading: state.lastBidderPortfolioIsLoading, + data: state.lastBidderPortfolio, +}); + +export const mapDispatchToProps = dispatch => ({ + fetchData: () => dispatch(bidderPortfolioFetchDataFromLastQuery()), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ExportLink); diff --git a/src/Components/BidderPortfolio/ExportLink/ExportLink.test.jsx b/src/Components/BidderPortfolio/ExportLink/ExportLink.test.jsx new file mode 100644 index 0000000000..b961daaabc --- /dev/null +++ b/src/Components/BidderPortfolio/ExportLink/ExportLink.test.jsx @@ -0,0 +1,40 @@ +import { shallow } from 'enzyme'; +import React from 'react'; +import toJSON from 'enzyme-to-json'; +import { ExportLink, mapDispatchToProps } from './ExportLink'; +import { testDispatchFunctions } from '../../../testUtilities/testUtilities'; + +describe('SearchResultsExportLink', () => { + it('is defined', () => { + const wrapper = shallow(); + expect(wrapper).toBeDefined(); + }); + + it('calls onClick on button click', () => { + const wrapper = shallow(); + expect(wrapper.instance().state.isLoading).toBe(false); + wrapper.find('button').simulate('click'); + expect(wrapper.instance().state.isLoading).toBe(true); + }); + + it('sets state when data is done loading', () => { + const wrapper = shallow(); + const instance = wrapper.instance(); + const data = { results: [] }; + wrapper.setProps({ isLoading: false, hasErrored: false, data }); + expect(instance.state.isLoading).toBe(false); + expect(instance.state.data).toEqual(data.results); + }); + + it('matches snapshot', () => { + const wrapper = shallow(); + expect(toJSON(wrapper)).toMatchSnapshot(); + }); +}); + +describe('mapDispatchToProps', () => { + const config = { + fetchData: [], + }; + testDispatchFunctions(mapDispatchToProps, config); +}); diff --git a/src/Components/BidderPortfolio/ExportLink/__snapshots__/ExportLink.test.jsx.snap b/src/Components/BidderPortfolio/ExportLink/__snapshots__/ExportLink.test.jsx.snap new file mode 100644 index 0000000000..11fbbcfa23 --- /dev/null +++ b/src/Components/BidderPortfolio/ExportLink/__snapshots__/ExportLink.test.jsx.snap @@ -0,0 +1,64 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SearchResultsExportLink matches snapshot 1`] = ` +
+ + +
+`; diff --git a/src/Components/BidderPortfolio/ExportLink/index.js b/src/Components/BidderPortfolio/ExportLink/index.js new file mode 100644 index 0000000000..0a55407cd1 --- /dev/null +++ b/src/Components/BidderPortfolio/ExportLink/index.js @@ -0,0 +1 @@ +export { default } from './ExportLink'; diff --git a/src/Components/BidderPortfolio/TopNav/TopNav.jsx b/src/Components/BidderPortfolio/TopNav/TopNav.jsx index 4f2edc0aab..7d3d8c9be6 100644 --- a/src/Components/BidderPortfolio/TopNav/TopNav.jsx +++ b/src/Components/BidderPortfolio/TopNav/TopNav.jsx @@ -1,16 +1,12 @@ import React from 'react'; import { BIDDER_PORTFOLIO_COUNTS } from '../../../Constants/PropTypes'; import Navigation from './Navigation'; -import Actions from './Actions'; const TopNav = ({ bidderPortfolioCounts }) => (
-
+
-
- -
); diff --git a/src/Components/BidderPortfolio/TopNav/__snapshots__/TopNav.test.jsx.snap b/src/Components/BidderPortfolio/TopNav/__snapshots__/TopNav.test.jsx.snap index ed294c5174..6474d0352d 100644 --- a/src/Components/BidderPortfolio/TopNav/__snapshots__/TopNav.test.jsx.snap +++ b/src/Components/BidderPortfolio/TopNav/__snapshots__/TopNav.test.jsx.snap @@ -5,7 +5,7 @@ exports[`TopNavComponent matches snapshot 1`] = ` className="usa-grid-full portfolio-top-nav-container" >
-
- -
`; diff --git a/src/actions/bidderPortfolio.js b/src/actions/bidderPortfolio.js index 0f9638426d..e55177a912 100644 --- a/src/actions/bidderPortfolio.js +++ b/src/actions/bidderPortfolio.js @@ -19,6 +19,25 @@ export function bidderPortfolioFetchDataSuccess(results) { }; } +export function lastBidderPortfolioHasErrored(bool) { + return { + type: 'LAST_BIDDER_PORTFOLIO_HAS_ERRORED', + hasErrored: bool, + }; +} +export function lastBidderPortfolioIsLoading(bool) { + return { + type: 'LAST_BIDDER_PORTFOLIO_IS_LOADING', + isLoading: bool, + }; +} +export function lastBidderPortfolioFetchDataSuccess(results) { + return { + type: 'LAST_BIDDER_PORTFOLIO_FETCH_DATA_SUCCESS', + results, + }; +} + export function bidderPortfolioCountsHasErrored(bool) { return { type: 'BIDDER_PORTFOLIO_COUNTS_HAS_ERRORED', @@ -38,6 +57,14 @@ export function bidderPortfolioCountsFetchDataSuccess(counts) { }; } +export function bidderPortfolioLastQuery(query, count) { + return { + type: 'SET_BIDDER_PORTFOLIO_LAST_QUERY', + query, + count, + }; +} + export function bidderPortfolioCountsFetchData() { return (dispatch) => { dispatch(bidderPortfolioCountsIsLoading(true)); @@ -57,10 +84,12 @@ export function bidderPortfolioCountsFetchData() { export function bidderPortfolioFetchData(query = '') { return (dispatch) => { + const q = `/client/?${query}`; dispatch(bidderPortfolioIsLoading(true)); dispatch(bidderPortfolioHasErrored(false)); - api().get(`/client/?${query}`) + api().get(q) .then(({ data }) => { + dispatch(bidderPortfolioLastQuery(query, data.count)); dispatch(bidderPortfolioHasErrored(false)); dispatch(bidderPortfolioIsLoading(false)); dispatch(bidderPortfolioFetchDataSuccess(data)); @@ -71,3 +100,21 @@ export function bidderPortfolioFetchData(query = '') { }); }; } + +export function bidderPortfolioFetchDataFromLastQuery() { + return (dispatch, getState) => { + dispatch(lastBidderPortfolioIsLoading(true)); + dispatch(lastBidderPortfolioHasErrored(false)); + const q = getState().bidderPortfolioLastQuery; + api().get(q) + .then(({ data }) => { + dispatch(lastBidderPortfolioFetchDataSuccess(data)); + dispatch(lastBidderPortfolioHasErrored(false)); + dispatch(lastBidderPortfolioIsLoading(false)); + }) + .catch(() => { + dispatch(lastBidderPortfolioHasErrored(true)); + dispatch(lastBidderPortfolioIsLoading(false)); + }); + }; +} diff --git a/src/actions/bidderPortfolio.test.js b/src/actions/bidderPortfolio.test.js index 9e30086237..e551d05862 100644 --- a/src/actions/bidderPortfolio.test.js +++ b/src/actions/bidderPortfolio.test.js @@ -33,6 +33,36 @@ describe('bidderPortfolio async actions', () => { f(); }); + it('can fetch data from the last query', (done) => { + const store = mockStore({ results: [] }); + + const f = () => { + setTimeout(() => { + store.dispatch(actions.bidderPortfolioFetchDataFromLastQuery()); + store.dispatch(actions.lastBidderPortfolioIsLoading()); + done(); + }, 0); + }; + f(); + }); + + it('can handle failures when fetching data from the last query', (done) => { + const store = mockStore({ results: [] }); + + mockAdapter.onGet('http://localhost:8000/api/v1/client/?').reply(404, + null, + ); + + const f = () => { + setTimeout(() => { + store.dispatch(actions.bidderPortfolioFetchData('q=failure')); + store.dispatch(actions.bidderPortfolioIsLoading()); + done(); + }, 0); + }; + f(); + }); + it("can handle failures when fetching a CDO's client/bidder list", (done) => { const store = mockStore({ results: [] }); diff --git a/src/reducers/bidderPortfolio/bidderPortfolio.js b/src/reducers/bidderPortfolio/bidderPortfolio.js index 44166b0abd..b84340ae22 100644 --- a/src/reducers/bidderPortfolio/bidderPortfolio.js +++ b/src/reducers/bidderPortfolio/bidderPortfolio.js @@ -1,3 +1,5 @@ +import queryString from 'query-string'; + export function bidderPortfolioHasErrored(state = false, action) { switch (action.type) { case 'BIDDER_PORTFOLIO_HAS_ERRORED': @@ -23,6 +25,31 @@ export function bidderPortfolio(state = { results: [] }, action) { } } +export function lastBidderPortfolioHasErrored(state = false, action) { + switch (action.type) { + case 'LAST_BIDDER_PORTFOLIO_HAS_ERRORED': + return action.hasErrored; + default: + return state; + } +} +export function lastBidderPortfolioIsLoading(state = false, action) { + switch (action.type) { + case 'LAST_BIDDER_PORTFOLIO_IS_LOADING': + return action.isLoading; + default: + return state; + } +} +export function lastBidderPortfolio(state = { results: [] }, action) { + switch (action.type) { + case 'LAST_BIDDER_PORTFOLIO_FETCH_DATA_SUCCESS': + return action.results; + default: + return state; + } +} + export function bidderPortfolioCountsHasErrored(state = false, action) { switch (action.type) { case 'BIDDER_PORTFOLIO_COUNTS_HAS_ERRORED': @@ -47,3 +74,18 @@ export function bidderPortfolioCounts(state = {}, action) { return state; } } + +export function bidderPortfolioLastQuery(state = '/client/', action) { + switch (action.type) { + case 'SET_BIDDER_PORTFOLIO_LAST_QUERY': { + const base = '/client/'; + const q = queryString.parse(action.query); + q.limit = action.count; + const stringified = queryString.stringify(q); + const newState = `${base}?${stringified}`; + return newState; + } + default: + return state; + } +} diff --git a/src/reducers/bidderPortfolio/bidderPortfolio.test.js b/src/reducers/bidderPortfolio/bidderPortfolio.test.js index 9789540291..1f5d3e41e6 100644 --- a/src/reducers/bidderPortfolio/bidderPortfolio.test.js +++ b/src/reducers/bidderPortfolio/bidderPortfolio.test.js @@ -29,3 +29,23 @@ describe('bidderPortfolioCounts reducers', () => { {}, { type: 'BIDDER_PORTFOLIO_COUNTS_FETCH_DATA_SUCCESS', counts: {} })).toBeDefined(); }); }); + +describe('lastBidderPortfolio reducers', () => { + it('can set reducer LAST_BIDDER_PORTFOLIO_HAS_ERRORED', () => { + expect(reducers.lastBidderPortfolioHasErrored(false, { type: 'LAST_BIDDER_PORTFOLIO_HAS_ERRORED', hasErrored: true })).toBe(true); + }); + + it('can set reducer LAST_BIDDER_PORTFOLIO_IS_LOADING', () => { + expect(reducers.lastBidderPortfolioIsLoading(false, { type: 'LAST_BIDDER_PORTFOLIO_IS_LOADING', isLoading: true })).toBe(true); + }); + + it('can set reducer LAST_BIDDER_PORTFOLIO_FETCH_DATA_SUCCESS', () => { + expect(reducers.lastBidderPortfolio( + {}, { type: 'LAST_BIDDER_PORTFOLIO_FETCH_DATA_SUCCESS', results: {} })).toBeDefined(); + }); + + it('can set reducer SET_BIDDER_PORTFOLIO_LAST_QUERY', () => { + expect(reducers.bidderPortfolioLastQuery( + {}, { type: 'SET_BIDDER_PORTFOLIO_LAST_QUERY', query: '&ordering=id', count: 5 })).toBe('/client/?limit=5&ordering=id'); + }); +}); diff --git a/src/reducers/bidderPortfolio/index.js b/src/reducers/bidderPortfolio/index.js index c14259c644..22cdd9e4a4 100644 --- a/src/reducers/bidderPortfolio/index.js +++ b/src/reducers/bidderPortfolio/index.js @@ -1,9 +1,15 @@ import { bidderPortfolio, bidderPortfolioIsLoading, bidderPortfolioHasErrored, - bidderPortfolioCounts, bidderPortfolioCountsIsLoading, bidderPortfolioCountsHasErrored } from './bidderPortfolio'; + bidderPortfolioCounts, bidderPortfolioCountsIsLoading, bidderPortfolioCountsHasErrored, + bidderPortfolioLastQuery, lastBidderPortfolioHasErrored, lastBidderPortfolioIsLoading, + lastBidderPortfolio } from './bidderPortfolio'; export default { bidderPortfolio, bidderPortfolioIsLoading, bidderPortfolioHasErrored, bidderPortfolioCounts, bidderPortfolioCountsIsLoading, - bidderPortfolioCountsHasErrored }; + bidderPortfolioCountsHasErrored, + bidderPortfolioLastQuery, + lastBidderPortfolioHasErrored, + lastBidderPortfolioIsLoading, + lastBidderPortfolio }; diff --git a/src/sass/_bidderPortfolio.scss b/src/sass/_bidderPortfolio.scss index 9625b22b70..6e28a6503d 100644 --- a/src/sass/_bidderPortfolio.scss +++ b/src/sass/_bidderPortfolio.scss @@ -245,6 +245,30 @@ width: 50px; } + .export-button-container { + margin-left: 10px; + + button { + align-items: center; + display: flex; + justify-content: center; + min-width: 100px; + } + + .ds-c-spinner { + height: 11px; + margin-right: 5px; + width: 11px; + + &::before, + &::after { + border-width: 2px; + height: 11px; + width: 11px; + } + } + } + .bidder-portfolio-search-container { background-color: $color-gray-lighter; padding-bottom: 3px; @@ -257,14 +281,12 @@ } .portfolio-sort-container-contents { + align-items: center; + display: flex; float: right; - .usa-form { - float: left; - } - - .results-viewby-container { - float: left; + button { + margin: 0; } }