Skip to content

Commit

Permalink
feat: admin export reports csv (#554)
Browse files Browse the repository at this point in the history
  • Loading branch information
banders authored Jun 25, 2024
1 parent 594a04b commit d56189a
Show file tree
Hide file tree
Showing 12 changed files with 513 additions and 95 deletions.
20 changes: 15 additions & 5 deletions admin-frontend/src/components/ReportsPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@
class="btn-primary"
prepend-icon="mdi-export"
@click="exportResults()"
:disabled="!searchResults?.length"
:disabled="!searchResults?.length || isDownloadingCsv"
:loading="isDownloadingCsv"
>
Export results
Export results (CSV)
</v-btn>
</v-col>
</v-row>
Expand Down Expand Up @@ -98,8 +99,15 @@ const displayDateFormatter = DateTimeFormatter.ofPattern(
const reportsCurrentlyBeingDownloaded = ref({});
const reportSearchStore = useReportSearchStore();
const { searchResults, isSearching, hasSearched, totalNum, pageSize } =
storeToRefs(reportSearchStore);
const {
searchResults,
isSearching,
hasSearched,
totalNum,
pageSize,
lastSubmittedReportSearchParams,
isDownloadingCsv,
} = storeToRefs(reportSearchStore);
const confirmDialog = ref<typeof ConfirmationDialog>();
const itemsPerPageOptions = ref([
{ value: 1, title: '1' },
Expand Down Expand Up @@ -202,7 +210,9 @@ function isDownloadingPdf(reportId: string) {
}
function exportResults() {
console.log('Todo: implement export');
if (lastSubmittedReportSearchParams.value) {
reportSearchStore.downloadReportsCsv(lastSubmittedReportSearchParams.value);
}
}
/*
Expand Down
16 changes: 16 additions & 0 deletions admin-frontend/src/components/__tests__/ReportsPage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import ApiService from '../../services/apiService';
import { useReportSearchStore } from '../../store/modules/reportSearchStore';
import ReportsPage from '../ReportsPage.vue';

// Mock the ResizeObserver
Expand All @@ -21,6 +22,7 @@ vi.stubGlobal('URL', { createObjectURL: vi.fn() });
describe('ReportsPage', () => {
let wrapper;
let pinia;
let reportSearchStore;

const initWrapper = async (options: any = {}) => {
const vuetify = createVuetify({
Expand All @@ -36,6 +38,7 @@ describe('ReportsPage', () => {
plugins: [vuetify, pinia],
},
});
reportSearchStore = useReportSearchStore();

//wait for the async component to load
await flushPromises();
Expand Down Expand Up @@ -108,4 +111,17 @@ describe('ReportsPage', () => {
).toBeFalsy();
});
});

describe('exportResults', () => {
it('delegates to reportSearchStore', async () => {
const downloadReportsCsvSpy = vi
.spyOn(reportSearchStore, 'downloadReportsCsv')
.mockResolvedValue();
wrapper.vm.lastSubmittedReportSearchParams = { filter: [] };
wrapper.vm.exportResults();
expect(downloadReportsCsvSpy).toHaveBeenCalledWith(
wrapper.vm.lastSubmittedReportSearchParams,
);
});
});
});
61 changes: 61 additions & 0 deletions admin-frontend/src/services/__tests__/apiService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ vi.mock('axios', async () => {
};
});

const mockSaveAs = vi.fn();
vi.mock('file-saver', async () => {
return { saveAs: (...args) => mockSaveAs(...args) };
});

describe('ApiService', () => {
beforeEach(() => {});

Expand Down Expand Up @@ -104,6 +109,62 @@ describe('ApiService', () => {
});
});

describe('downloadReportsCsv', () => {
describe('when valid filter and sort are passed, and the backend returns a valid response', () => {
it('the browser saves the downloaded file', async () => {
const filter = [
{ key: 'reporting_year', operation: 'eq', value: 2024 },
];
const sort = [{ company_name: 'asc' }];
const mockAxiosResp = {
data: 'A,B,C\na,b,c\n',
headers: {
'content-type': 'text/csv',
},
};
vi.spyOn(ApiService.apiAxios, 'get').mockResolvedValueOnce(
mockAxiosResp,
);

await ApiService.downloadReportsCsv(filter, sort);
expect(mockSaveAs).toHaveBeenCalledOnce();
const savedBlob = mockSaveAs.mock.calls[0][0];
expect(savedBlob.type).toBe(mockAxiosResp.headers['content-type']);
});
});
describe('when the backend returns an error response', () => {
it('throws an error', async () => {
const filter = [
{ key: 'reporting_year', operation: 'eq', value: 2024 },
];
const sort = [{ company_name: 'asc' }];
vi.spyOn(ApiService.apiAxios, 'get').mockRejectedValue(
new Error('Some backend error occurred'),
);

await expect(
ApiService.downloadReportsCsv(filter, sort),
).rejects.toThrow();
expect(mockSaveAs).toHaveBeenCalledTimes(0);
});
});
describe('when the backend returns an invalid response with a success code', () => {
it('throws an error', async () => {
const filter = [
{ key: 'reporting_year', operation: 'eq', value: 2024 },
];
const sort = [{ company_name: 'asc' }];
const mockAxiosResp = {};
vi.spyOn(ApiService.apiAxios, 'get').mockRejectedValue(mockAxiosResp);

await expect(
ApiService.downloadReportsCsv(filter, sort),
).rejects.toThrow();
expect(mockSaveAs).toHaveBeenCalledTimes(0);
});
});
});

describe('getPdfReportAsBlob', () => {
describe('when the given report id is valid', () => {
it('returns a blob', async () => {
Expand Down
38 changes: 37 additions & 1 deletion admin-frontend/src/services/apiService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios from 'axios';
import { saveAs } from 'file-saver';
import { IConfigValue, IReportSearchResult, User } from '../types';
import { ApiRoutes } from '../utils/constant';
import AuthService from './authService';
Expand Down Expand Up @@ -142,7 +143,7 @@ export default {
filter = [];
}
if (!sort) {
sort = [{ create_date: 'asc' }];
sort = [{ update_date: 'desc' }];
}
const params = {
offset: offset,
Expand All @@ -163,6 +164,41 @@ export default {
}
},

/**
* Download a list of reports in csv format. This method also causes
* the browser to save the resulting file.
*/
async downloadReportsCsv(
filter: any[] | null = null,
sort: any[] | null = null,
) {
try {
if (!filter) {
filter = [];
}
if (!sort) {
sort = [{ update_date: 'desc' }];
}
const resp = await apiAxios.get(ApiRoutes.REPORTS, {
headers: { accept: 'text/csv' },
params: { filter: JSON.stringify(filter), sort: JSON.stringify(sort) },
});

if (resp?.data) {
//make the browser save the file
const blob = new Blob([resp.data], {
type: resp.headers['content-type'],
});
saveAs(blob, 'pay-transparency-reports.csv');
} else {
throw new Error('Unable to download reports in CSV format');
}
} catch (e) {
console.log(`Failed to get reports in CSV format - ${e}`);
throw e;
}
},

/**
* Downloads a PDF of the report with the given id as a blob.
* Returns the blob.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ApiService from '../../../services/apiService';
import { IReportSearchParams, IReportSearchUpdateParams } from '../../../types';
import {
DEFAULT_PAGE_SIZE,
Expand All @@ -9,11 +10,15 @@ import {
} from '../reportSearchStore';

const mockGetReports = vi.fn();
vi.mock('../../../services/apiService', () => ({
default: {
getReports: () => mockGetReports(),
},
}));
vi.mock('../../../services/apiService', async (importOriginal) => {
const mod: any = await importOriginal();
return {
default: {
...mod.default,
getReports: () => mockGetReports(),
},
};
});

describe('reportSearchStore', () => {
let reportSearchStore;
Expand Down Expand Up @@ -189,4 +194,41 @@ describe('reportSearchStore', () => {
);
});
});

describe('downloadReportsCsv', () => {
describe('given filter and sort', () => {
it('delegates to the ApiService, passing the given filter and sort', async () => {
const params: IReportSearchParams = {
filter: [],
sort: [{ reporting_year: 'desc' }],
};
const spy = vi
.spyOn(ApiService, 'downloadReportsCsv')
.mockResolvedValue();

await reportSearchStore.downloadReportsCsv(params);

expect(spy.mock.calls[0][0]).toBe(params.filter);
expect(spy.mock.calls[0][1]).toBe(params.sort);
expect(reportSearchStore.isDownloadingCsv).toBeFalsy();
});
});
describe('given empty filter and empty sort', () => {
it('delegates to the ApiService, passing a default sort', async () => {
const params: IReportSearchParams = {
filter: [],
sort: [],
};
const spy = vi
.spyOn(ApiService, 'downloadReportsCsv')
.mockResolvedValue();

await reportSearchStore.downloadReportsCsv(params);

expect(spy.mock.calls[0][0]).toBe(params.filter);
expect(spy.mock.calls[0][1]).toBe(DEFAULT_SEARCH_PARAMS.sort);
expect(reportSearchStore.isDownloadingCsv).toBeFalsy();
});
});
});
});
32 changes: 32 additions & 0 deletions admin-frontend/src/store/modules/reportSearchStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export const DEFAULT_SEARCH_PARAMS: IReportSearchParams = {
filter: undefined,
sort: [{ update_date: 'desc' }],
};
export const DEFAULT_DOWNLOAD_CSV_PARAMS: IReportSearchParams = {
filter: undefined,
sort: [{ update_date: 'desc' }],
};

/*
Stores report search results and provides functions to fetch new
Expand All @@ -31,6 +35,7 @@ export const useReportSearchStore = defineStore('reportSearch', () => {
const searchResults = ref<any[] | undefined>(undefined);
const totalNum = ref(0);
const isSearching = ref(false);
const isDownloadingCsv = ref(false);
const pageSize = ref(DEFAULT_PAGE_SIZE);
const hasSearched = computed(
() =>
Expand Down Expand Up @@ -120,6 +125,31 @@ export const useReportSearchStore = defineStore('reportSearch', () => {
await searchReports(DEFAULT_SEARCH_PARAMS);
};

/*
Downloads a CSV of reports. CSV output will not be subdivided
into "pages". (i.e. if 'page' and 'itemsPerPage' params are specified,
they are ignored.)
*/
const downloadReportsCsv = async (params: IReportSearchParams) => {
const searchParams: any = { ...DEFAULT_DOWNLOAD_CSV_PARAMS, ...params };

const filter = searchParams.filter;
let sort = searchParams.sort;

if (!sort?.length) {
sort = DEFAULT_SEARCH_PARAMS.sort;
}

isDownloadingCsv.value = true;

try {
await ApiService.downloadReportsCsv(filter, sort);
} catch (err) {
console.log(`Unable to download CSV: ${err}`);
}
isDownloadingCsv.value = false;
};

// Private actions
//---------------------------------------------------------------------------

Expand Down Expand Up @@ -158,6 +188,7 @@ export const useReportSearchStore = defineStore('reportSearch', () => {
lastSubmittedReportSearchParams,
searchResults,
isSearching,
isDownloadingCsv,
totalNum,
pageSize,
hasSearched,
Expand All @@ -166,5 +197,6 @@ export const useReportSearchStore = defineStore('reportSearch', () => {
updateSearch,
repeatSearch,
reset,
downloadReportsCsv,
};
});
6 changes: 6 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"multer": "^1.4.5-lts.1",
"nconf": "^0.12.1",
"nocache": "^4.0.0",
"papaparse": "^5.4.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-openidconnect-keycloak-idp": "^0.1.6",
Expand Down
Loading

0 comments on commit d56189a

Please sign in to comment.