diff --git a/src/pages/DataExport/form-helper.js b/src/pages/DataExport/form-helper.js index 45a598d60..b0f92e8e2 100644 --- a/src/pages/DataExport/form-helper.js +++ b/src/pages/DataExport/form-helper.js @@ -3,7 +3,7 @@ import { DATE_AFTER_VALIDATOR, } from '../../components/DatePicker/DatePickerField.jsx' import { - locationAssign, + fetchAndDownload, compressionToName, pathToId, } from '../../utils/helper.js' @@ -44,11 +44,15 @@ const onExport = (baseUrl, setExportEnabled) => async (values) => { const downloadUrlParams = valuesToParams(values) const endpointExtension = values.format const url = `${apiBaseUrl}${endpoint}.${endpointExtension}?${downloadUrlParams}` - locationAssign(url) - setExportEnabled(true) - // log for debugging purposes - console.log('data-export:', { url, params: downloadUrlParams }) + try { + return await fetchAndDownload(url, 'data') + } finally { + setExportEnabled(true) + + // log for debugging purposes + console.log('data-export:', { url, params: downloadUrlParams }) + } } const validate = (values) => ({ diff --git a/src/pages/EventExport/form-helper.js b/src/pages/EventExport/form-helper.js index f5f22a66e..a9e907e08 100644 --- a/src/pages/EventExport/form-helper.js +++ b/src/pages/EventExport/form-helper.js @@ -3,22 +3,7 @@ import { DATE_AFTER_VALIDATOR, } from '../../components/DatePicker/DatePickerField.jsx' import { ALL_VALUE } from '../../hooks/useProgramStages.js' -import { FORM_ERROR } from '../../utils/final-form.js' -import { - genericErrorMessage, - locationAssign, - pathToId, -} from '../../utils/helper.js' - -const exportErrorAlert = (message) => ({ - [FORM_ERROR]: [ - { - id: `event-export-error-${Date.now()}`, - warning: true, - message, - }, - ], -}) +import { fetchAndDownload, pathToId } from '../../utils/helper.js' const onExport = (baseUrl, setExportEnabled) => async (values) => { setExportEnabled(false) @@ -61,30 +46,12 @@ const onExport = (baseUrl, setExportEnabled) => async (values) => { const url = `${apiBaseUrl}${endpoint}.${endpointExtension}?${downloadUrlParams}` try { - const response = await fetch(url, { credentials: 'include' }) - - if (!response.ok) { - let message = genericErrorMessage - try { - const body = await response.json() - message = body.message || message - } catch (e) { - // response body wasn't JSON, fall back to the generic message - console.error('event-export: failed to parse error response', e) - } - return exportErrorAlert(message) - } - - const blob = await response.blob() - locationAssign(url, blob) + return await fetchAndDownload(url, 'event') + } finally { + setExportEnabled(true) // log for debugging purposes console.log('event-export:', { url, params: downloadUrlParams }) - } catch (e) { - console.error('event-export: request failed', e) - return exportErrorAlert(genericErrorMessage) - } finally { - setExportEnabled(true) } } diff --git a/src/pages/MetadataDependencyExport/MetadataDependencyExport.jsx b/src/pages/MetadataDependencyExport/MetadataDependencyExport.jsx index 823446df4..458cbcb36 100644 --- a/src/pages/MetadataDependencyExport/MetadataDependencyExport.jsx +++ b/src/pages/MetadataDependencyExport/MetadataDependencyExport.jsx @@ -18,6 +18,7 @@ import { Objects, SkipSharing, ExportButton, + FormAlerts, } from '../../components/Inputs/index.js' import { onExport } from './form-helper.js' @@ -56,8 +57,9 @@ const MetadataDependencyExport = () => { initialValues={initialValues} subscription={{ values: true, + submitError: true, }} - render={({ handleSubmit, form, values }) => ( + render={({ handleSubmit, form, values, submitError }) => (
@@ -69,6 +71,7 @@ const MetadataDependencyExport = () => { label={i18n.t('Export metadata dependencies')} disabled={!exportEnabled} /> + )} /> diff --git a/src/pages/MetadataDependencyExport/form-helper.js b/src/pages/MetadataDependencyExport/form-helper.js index ca7dc2526..8a6799af9 100644 --- a/src/pages/MetadataDependencyExport/form-helper.js +++ b/src/pages/MetadataDependencyExport/form-helper.js @@ -1,6 +1,6 @@ -import { locationAssign } from '../../utils/helper.js' +import { fetchAndDownload } from '../../utils/helper.js' -const onExport = (baseUrl, setExportEnabled) => (values) => { +const onExport = (baseUrl, setExportEnabled) => async (values) => { setExportEnabled(false) const { objectType, object, format, compression, skipSharing } = values @@ -11,14 +11,18 @@ const onExport = (baseUrl, setExportEnabled) => (values) => { const endpointExtension = compression ? `${format}.${compression}` : format const downloadUrlParams = `skipSharing=${skipSharing}&download=true` const url = `${apiBaseUrl}${endpoint}.${endpointExtension}?${downloadUrlParams}` - locationAssign(url) - setExportEnabled(true) - // log for debugging purposes - console.log('metadata-dependency-export:', { - url, - params: downloadUrlParams, - }) + try { + return await fetchAndDownload(url, 'metadata-dependency') + } finally { + setExportEnabled(true) + + // log for debugging purposes + console.log('metadata-dependency-export:', { + url, + params: downloadUrlParams, + }) + } } export { onExport } diff --git a/src/pages/MetadataExport/MetadataExport.jsx b/src/pages/MetadataExport/MetadataExport.jsx index 5469b49f4..d4bbc5318 100644 --- a/src/pages/MetadataExport/MetadataExport.jsx +++ b/src/pages/MetadataExport/MetadataExport.jsx @@ -19,6 +19,7 @@ import { InclusionStrategy, defaultInclusionStrategyOption, ExportButton, + FormAlerts, } from '../../components/Inputs/index.js' import { onExport } from './form-helper.js' @@ -56,7 +57,11 @@ const MetadataExport = () => {
( + subscription={{ + values: true, + submitError: true, + }} + render={({ handleSubmit, submitError }) => ( @@ -70,6 +75,7 @@ const MetadataExport = () => { label={i18n.t('Export metadata')} disabled={!exportEnabled} /> + )} /> diff --git a/src/pages/MetadataExport/form-helper.js b/src/pages/MetadataExport/form-helper.js index 48aaa8f88..faad8c26a 100644 --- a/src/pages/MetadataExport/form-helper.js +++ b/src/pages/MetadataExport/form-helper.js @@ -1,6 +1,6 @@ -import { locationAssign } from '../../utils/helper.js' +import { fetchAndDownload } from '../../utils/helper.js' -const onExport = (baseUrl, setExportEnabled) => (values) => { +const onExport = (baseUrl, setExportEnabled) => async (values) => { setExportEnabled(false) const { @@ -18,11 +18,15 @@ const onExport = (baseUrl, setExportEnabled) => (values) => { const schemaParams = checkedSchemas.map((name) => `${name}=true`).join('&') const downloadUrlParams = `skipSharing=${skipSharing}&inclusionStrategy=${inclusionStrategy}&download=true&${schemaParams}` const url = `${apiBaseUrl}${endpoint}.${endpointExtension}?${downloadUrlParams}` - locationAssign(url) - setExportEnabled(true) - // log for debugging purposes - console.log('metadata-export:', { url, params: downloadUrlParams }) + try { + return await fetchAndDownload(url, 'metadata') + } finally { + setExportEnabled(true) + + // log for debugging purposes + console.log('metadata-export:', { url, params: downloadUrlParams }) + } } export { onExport } diff --git a/src/pages/TEIExport/form-helper.js b/src/pages/TEIExport/form-helper.js index 9bfead013..ea5d0cdb9 100644 --- a/src/pages/TEIExport/form-helper.js +++ b/src/pages/TEIExport/form-helper.js @@ -4,7 +4,7 @@ import { DATE_AFTER_VALIDATOR, } from '../../components/DatePicker/DatePickerField.jsx' import { OU_MODE_MANUAL_VALUE } from '../../components/Inputs/index.js' -import { locationAssign, pathToId } from '../../utils/helper.js' +import { fetchAndDownload, pathToId } from '../../utils/helper.js' // calculate minimum set of parameters based on given filters const valuesToParams = ({ @@ -112,11 +112,15 @@ const onExport = (baseUrl, setExportEnabled) => async (values) => { const endpoint = `trackedEntities` const downloadUrlParams = valuesToParams(values) const url = `${apiBaseUrl}${endpoint}.${format}?${downloadUrlParams}` - locationAssign(url) - setExportEnabled(true) - // log for debugging purposes - console.log('tei-export:', { url, params: downloadUrlParams }) + try { + return await fetchAndDownload(url, 'tei') + } finally { + setExportEnabled(true) + + // log for debugging purposes + console.log('tei-export:', { url, params: downloadUrlParams }) + } } const validate = (values) => { diff --git a/src/utils/helper.js b/src/utils/helper.js index b22fa891b..a191a648d 100644 --- a/src/utils/helper.js +++ b/src/utils/helper.js @@ -1,4 +1,5 @@ import i18n from '@dhis2/d2-i18n' +import { FORM_ERROR } from './final-form.js' import { getUploadXHR } from './xhr.js' const trimString = (length, string) => @@ -214,6 +215,46 @@ const locationAssign = (url, blob) => { } } +const exportErrorAlert = (source, message) => ({ + [FORM_ERROR]: [ + { + id: `${source}-export-error-${Date.now()}`, + warning: true, + message, + }, + ], +}) + +// fetches the export URL, and only triggers the download if the server +// responded with a success status otherwise returns an error alert +const fetchAndDownload = async (url, source) => { + try { + const response = await fetch(url, { credentials: 'include' }) + + if (!response.ok) { + let message = genericErrorMessage + try { + const body = await response.json() + message = body.message || message + } catch (e) { + // response body wasn't JSON, fall back to the generic message + console.error( + `${source}-export: failed to parse error response`, + e + ) + } + return exportErrorAlert(source, message) + } + + const blob = await response.blob() + locationAssign(url, blob) + return undefined + } catch (e) { + console.error(`${source}-export: request failed`, e) + return exportErrorAlert(source, genericErrorMessage) + } +} + const getPrevJobDetails = (query, tasks) => { if (!query || !query.id) { return {} @@ -245,6 +286,7 @@ const formatNumber = (value, locale) => { export { fetchAttributes, + fetchAndDownload, getPrevJobDetails, getInitialBoolValue, formatNumber, diff --git a/src/utils/helper.test.js b/src/utils/helper.test.js index bc98b3caa..9f9e24d73 100644 --- a/src/utils/helper.test.js +++ b/src/utils/helper.test.js @@ -1,4 +1,5 @@ -import { formatNumber, locationAssign } from './helper.js' +import { FORM_ERROR } from './final-form.js' +import { fetchAndDownload, formatNumber, locationAssign } from './helper.js' describe('formatNumber', () => { it('adds digit group separators to numbers', () => { @@ -79,3 +80,64 @@ describe('locationAssign', () => { expect(link.download).toEqual('events.json.zip') }) }) + +describe('fetchAndDownload', () => { + const url = 'https://debug.dhis2.org/dev/api/metadata.json' + + afterEach(() => { + global.fetch.mockRestore?.() + delete global.fetch + }) + + it('downloads the file and returns no error when the response is ok', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(['{}'])), + }) + + const result = await fetchAndDownload(url, 'metadata') + + expect(global.fetch).toHaveBeenCalledWith(url, { + credentials: 'include', + }) + expect(result).toBeUndefined() + }) + + it('returns a form error with the server message when the response is not ok', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ message: 'Conflict occurred' }), + }) + + const result = await fetchAndDownload(url, 'metadata') + + expect(result[FORM_ERROR]).toHaveLength(1) + expect(result[FORM_ERROR][0]).toMatchObject({ + warning: true, + message: 'Conflict occurred', + }) + }) + + it('falls back to a generic message when the error response has no JSON body', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + json: () => Promise.reject(new Error('not json')), + }) + + const result = await fetchAndDownload(url, 'metadata') + + expect(result[FORM_ERROR][0].message).toEqual( + 'An unknown error occurred. Please try again later' + ) + }) + + it('returns a form error when the request itself fails', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('network down')) + + const result = await fetchAndDownload(url, 'metadata') + + expect(result[FORM_ERROR][0].message).toEqual( + 'An unknown error occurred. Please try again later' + ) + }) +})