Skip to content

Commit

Permalink
Move export csv to grants next (#1900)
Browse files Browse the repository at this point in the history
  • Loading branch information
replicantSocks authored Sep 12, 2023
1 parent ed6c14b commit ea82e85
Show file tree
Hide file tree
Showing 4 changed files with 274 additions and 69 deletions.
9 changes: 5 additions & 4 deletions packages/client/src/components/GrantsTableNext.vue
Original file line number Diff line number Diff line change
Expand Up @@ -383,13 +383,14 @@ export default {
},
exportCSV() {
this.navigateToExportCSV({
perPage: this.perPage,
currentPage: this.currentPage,
orderBy: this.orderBy,
orderDesc: this.orderDesc,
interestedByAgency: this.showInterested || this.showResult || this.showRejected,
showInterested: this.showInterested,
showResult: this.showResult,
showRejected: this.showRejected,
assignedToAgency: this.showAssignedToAgency,
opportunityStatuses: this.parseOpportunityStatusFilters(),
opportunityCategories: this.opportunityCategoryFilters,
costSharing: this.costSharingFilter,
});
},
formatMoney(value) {
Expand Down
100 changes: 54 additions & 46 deletions packages/client/src/store/modules/grants.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,48 @@ function initialState() {
};
}

function buildGrantsNextQuery({ filters, ordering, pagination }) {
/*
costSharing
eligibility
excludeKeywords
fundingType
includeKeywords
opportunityCategories
opportunityNumber
opportunityStatuses
postedWithin
reviewStatus
bill
*/
const criteria = { ...filters };
// Validate and fix the inputs into appropriate types.
criteria.includeKeywords = criteria.includeKeywords && criteria.includeKeywords.length > 0 ? criteria.includeKeywords.split(',').map((k) => k.trim()) : null;
criteria.excludeKeywords = criteria.excludeKeywords && criteria.excludeKeywords.length > 0 ? criteria.excludeKeywords.split(',').map((k) => k.trim()) : null;
criteria.eligibility = criteria.eligibility?.map((e) => e.code);
criteria.fundingTypes = criteria.fundingTypes?.map((f) => f.code);

const paginationQuery = Object.entries(pagination)
// filter out undefined and nulls since api expects parameters not present as undefined
// eslint-disable-next-line no-unused-vars
.filter(([key, value]) => value || typeof value === 'number')
.map(([key, value]) => `pagination[${encodeURIComponent(key)}]=${encodeURIComponent(value)}`)
.join('&');
const orderingQuery = Object.entries(ordering)
// filter out undefined and nulls since api expects parameters not present as undefined
// eslint-disable-next-line no-unused-vars
.filter(([key, value]) => value || typeof value === 'number')
.map(([key, value]) => `ordering[${encodeURIComponent(key)}]=${encodeURIComponent(value)}`)
.join('&');
const criteriaQuery = Object.entries(criteria)
// filter out undefined and nulls since api expects parameters not present as undefined
// eslint-disable-next-line no-unused-vars
.filter(([key, value]) => (typeof value === 'string' && value.length > 0) || typeof value === 'number' || (Array.isArray(value) && value.length > 0))
.map(([key, value]) => `criteria[${encodeURIComponent(key)}]=${encodeURIComponent(value)}`)
.join('&');
return { criteriaQuery, paginationQuery, orderingQuery };
}

export default {
namespaced: true,
state: initialState,
Expand Down Expand Up @@ -90,46 +132,10 @@ export default {
fetchGrantsNext({ commit }, {
currentPage, perPage, orderBy, orderDesc,
}) {
/*
costSharing
eligibility
excludeKeywords
fundingType
includeKeywords
opportunityCategories
opportunityNumber
opportunityStatuses
postedWithin
reviewStatus
bill
*/
const pagination = { currentPage, perPage };
const ordering = { orderBy, orderDesc };
const criteria = { ...this.state.grants.searchFormFilters };
// Validate and fix the inputs into appropriate types.
criteria.includeKeywords = criteria.includeKeywords && criteria.includeKeywords.length > 0 ? criteria.includeKeywords.split(',').map((k) => k.trim()) : null;
criteria.excludeKeywords = criteria.excludeKeywords && criteria.excludeKeywords.length > 0 ? criteria.excludeKeywords.split(',').map((k) => k.trim()) : null;
criteria.eligibility = criteria.eligibility?.map((e) => e.code);
criteria.fundingTypes = criteria.fundingTypes?.map((f) => f.code);

const paginationQuery = Object.entries(pagination)
// filter out undefined and nulls since api expects parameters not present as undefined
// eslint-disable-next-line no-unused-vars
.filter(([key, value]) => value || typeof value === 'number')
.map(([key, value]) => `pagination[${encodeURIComponent(key)}]=${encodeURIComponent(value)}`)
.join('&');
const orderingQuery = Object.entries(ordering)
// filter out undefined and nulls since api expects parameters not present as undefined
// eslint-disable-next-line no-unused-vars
.filter(([key, value]) => value || typeof value === 'number')
.map(([key, value]) => `ordering[${encodeURIComponent(key)}]=${encodeURIComponent(value)}`)
.join('&');
const criteriaQuery = Object.entries(criteria)
// filter out undefined and nulls since api expects parameters not present as undefined
// eslint-disable-next-line no-unused-vars
.filter(([key, value]) => (typeof value === 'string' && value.length > 0) || typeof value === 'number' || (Array.isArray(value) && value.length > 0))
.map(([key, value]) => `criteria[${encodeURIComponent(key)}]=${encodeURIComponent(value)}`)
.join('&');
const filters = { ...this.state.grants.searchFormFilters };
const { criteriaQuery, paginationQuery, orderingQuery } = buildGrantsNextQuery({ filters, ordering, pagination });

return fetchApi.get(`/api/organizations/:organizationId/grants/next?${paginationQuery}&${orderingQuery}&${criteriaQuery}`)
.then((data) => commit('SET_GRANTS', data));
Expand Down Expand Up @@ -230,14 +236,16 @@ export default {
changeEditingSearchId({ commit }, searchId) {
commit('SET_EDITING_SEARCH_ID', searchId);
},
exportCSV(context, queryParams) {
const query = Object.entries(queryParams)
// filter out undefined and nulls since api expects parameters not present as undefined
// eslint-disable-next-line no-unused-vars
.filter(([key, value]) => value || typeof value === 'number')
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
const navUrl = fetchApi.apiURL(fetchApi.addOrganizationId(`/api/organizations/:organizationId/grants/exportCSV?${query}`));
exportCSV({
currentPage, perPage, orderBy, orderDesc,
}) {
const pagination = { currentPage, perPage };
const ordering = { orderBy, orderDesc };
const filters = { ...this.state.grants.searchFormFilters };
const { criteriaQuery, paginationQuery, orderingQuery } = buildGrantsNextQuery({ filters, ordering, pagination });
const navUrl = fetchApi.apiURL(fetchApi.addOrganizationId(
`/api/organizations/:organizationId/grants/exportCSVNew?${paginationQuery}&${orderingQuery}&${criteriaQuery}`,
));
window.location = navUrl;
},
exportCSVRecentActivities() {
Expand Down
121 changes: 121 additions & 0 deletions packages/server/__tests__/api/grants.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,127 @@ describe('`/api/grants` endpoint', () => {
});
});
});
context('GET /api/grants/exportCSVNew', () => {
it('produces correct column format', async () => {
// We constrain the result to a single grant that's listed in seeds/dev/ref/grants.js
const query = '?criteria[includeKeywords]=Community%20Health%20Aide Program:%20%20Tribal%20Planning';
const response = await fetchApi(`/grants/exportCSVNew${query}`, agencies.own, fetchOptions.staff);

expect(response.statusText).to.equal('OK');
expect(response.headers.get('Content-Type')).to.include('text/csv');
expect(response.headers.get('Content-Disposition')).to.include('attachment');

const expectedCsvHeaders = [
'Opportunity Number',
'Title',
'Viewed By',
'Interested Agencies',
'Status',
'Opportunity Category',
'Cost Sharing',
'Award Floor',
'Award Ceiling',
'Posted Date',
'Close Date',
'Agency Code',
'Grant Id',
'URL',
];
const txt = await response.text();
expect(txt.split('\n')[0]).to.equal(expectedCsvHeaders.join(','));
expect(txt.split('\n')[1]).to.contain('HHS-2021-IHS-TPI-0001,Community Health Aide Program: Tribal Planning &');
});

it('produces same number of rows as grid', async () => {
let response = await fetchApi(`/grants/exportCSVNew`, agencies.own, fetchOptions.staff);
expect(response.statusText).to.equal('OK');
expect(response.headers.get('Content-Type')).to.include('text/csv');
expect(response.headers.get('Content-Disposition')).to.include('attachment');
const responseText = await response.text();
const exportedRows = responseText.split(/\r?\n/);
const rowsHash = {};
let skipFirst = true;
// eslint-disable-next-line no-restricted-syntax
for (const row of exportedRows) {
if (skipFirst) {
skipFirst = false;
} else {
const cells = row.split(',');
if (cells[0]) {
rowsHash[cells[0]] = row;
}
}
}
let pageNumber = 1;
// eslint-disable-next-line no-constant-condition
while (true) {
// eslint-disable-next-line no-await-in-loop
response = await fetchApi(`/grants/next?pagination[currentPage]=${pageNumber}&pagination[perPage]=10&ordering[orderBy]=open_date&ordering[orderDesc]=true`, agencies.own, fetchOptions.staff);
expect(response.statusText).to.equal('OK');
// eslint-disable-next-line no-await-in-loop
const queryJson = await response.json();
if (queryJson.data.length === 0) {
break;
}
for (let j = 0; j < queryJson.data.length; j += 1) {
const opportunityNumber = queryJson.data[j].grant_number;
if (rowsHash[opportunityNumber]) {
delete rowsHash[opportunityNumber];
}
}
pageNumber += 1;
}
const extraRowCount = Object.keys(rowsHash).length;
if (extraRowCount > 0) {
console.log(JSON.stringify(rowsHash, null, 2));
expect(extraRowCount).to.equal(0);
}
});

it('limits number of output rows', async function testExport() {
// First we insert 100 grants (in prod this limit it 10k but it is reduced in test
// via NODE_ENV=test environment variable so this test isn't so slow)
const numToInsert = 100;
const grantsToInsert = Array(numToInsert).fill(undefined).map((val, i, arr) => ({
status: 'inbox',
grant_id: String(-(i + 1)),
grant_number: String(-(i + 1)),
agency_code: 'fake',
cost_sharing: 'No',
title: `fake grant #${i + 1}/${arr.length} for test`,
cfda_list: 'fake',
open_date: '2022-04-22',
close_date: '2022-04-22',
notes: 'auto-inserted by test',
search_terms: '[in title/desc]+',
reviewer_name: 'none',
opportunity_category: 'Discretionary',
description: 'fake grant inserted by test',
eligibility_codes: '25',
opportunity_status: 'posted',
raw_body: 'raw body',
}));

try {
this.timeout(10000);
await knex.batchInsert(TABLES.grants, grantsToInsert);

const response = await fetchApi(`/grants/exportCSVNew`, agencies.own, fetchOptions.staff);
expect(response.statusText).to.equal('OK');

const csv = await response.text();
const lines = csv.split('\n');

// 10k rows + 1 header + 1 error message row + line break at EOF
expect(lines.length).to.equal(numToInsert + 3);

const lastRow = lines[lines.length - 2];
expect(lastRow).to.include('Error:');
} finally {
await knex(TABLES.grants).where(knex.raw('cast(grant_id as INTEGER) < 0')).delete();
}
});
});
context('GET /api/grants/exportCSV', () => {
it('produces correct column format', async () => {
// We constrain the result to a single grant that's listed in seeds/dev/ref/grants.js
Expand Down
Loading

0 comments on commit ea82e85

Please sign in to comment.