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;
}
}