From d8f25284b26ff82f922ee0f7cb905c71f9a72583 Mon Sep 17 00:00:00 2001 From: Amy Chen Date: Tue, 19 Sep 2023 14:59:19 -0700 Subject: [PATCH] TN-3259 research data export - UI not showing the most current download link (#4339) Address story: [TN-3259](https://movember.atlassian.net/browse/TN-3259?atlOrigin=eyJpIjoiM2Q1MzQ2ZjE0YzY1NDBjMDk0OTM2OWMxMWMzMTc3ODAiLCJwIjoiamlyYS1zbGFjay1pbnQifQ) See also Slack convo [here](https://cirg.slack.com/archives/C5EU4L7PF/p1694733386101599) Changes include: - Whenever an data export is initiated on the UI, the frontend will cache the celery task URL associated with the export - Address the scenario where the export is left running in the background, e.g. as when user switches to a different browser tab - when data export window receives focus again, e.g. as when the user comes back from a different browser tab, the frontend will now check if the download has finished by calling the celery task URL cached from the previous step and update the download link on UI if it has finished successfully. - other change - enhance error checking --------- Co-authored-by: Amy Chen --- .../js/src/components/ExportInstruments.vue | 159 ++++++++++++++++-- .../src/components/asyncExportDataLoader.vue | 28 +-- 2 files changed, 159 insertions(+), 28 deletions(-) diff --git a/portal/static/js/src/components/ExportInstruments.vue b/portal/static/js/src/components/ExportInstruments.vue index 8acb342eea..d11cf5a6a6 100644 --- a/portal/static/js/src/components/ExportInstruments.vue +++ b/portal/static/js/src/components/ExportInstruments.vue @@ -57,6 +57,7 @@ :initElementId="getInitElementId()" :exportUrl="getExportUrl()" :exportIdentifier="currentStudy" + v-on:initExport="handleInitExport" v-on:doneExport="handleAfterExport" v-on:initExportCustomEvent="initExportEvent"> @@ -64,7 +65,7 @@
- +
@@ -98,7 +99,8 @@ subStudyIdentifier: "substudy", mainStudyInstrumentsList:[], subStudyInstrumentsList:[], - exportHistory: null + exportHistory: null, + currentTaskUrl: null }}; }, mixins: [CurrentUser], @@ -106,16 +108,16 @@ this.setCurrentMainStudy(); this.initCurrentUser(function() { this.getInstrumentList(); - this.setExportHistory(this.getCacheExportedDataInfo()); + this.handleSetExportHistory(); }.bind(this)); }, watch: { currentStudy: function(newVal, oldVal) { //watch for when study changes //reset last exported item link as it is specific to each study - this.setExportHistory(this.getCacheExportedDataInfo()); - //reset export error - this.resetExportError(); + this.handleSetExportHistory(); + //reset export display info + this.resetExportInfoUI(); //reset instrument(s) selected this.resetInstrumentSelections(); }, @@ -206,8 +208,11 @@ self.instruments.selected = arrSelected; }); $("#patientsInstrumentList [name='instrument'], #patientsDownloadTypeList [name='downloadType']").on("click", function() { - //clear pre-existing error - self.resetExportError(); + //clear pre-existing export info display + self.resetExportInfoUI(); + if (self.hasInstrumentsSelection()) { + $("#patientsDownloadButton").removeAttr("disabled"); + } }); //patientsDownloadTypeList downloadType $("#patientsDownloadTypeList [name='downloadType']").on("click", function() { @@ -218,15 +223,23 @@ } }); $("#dataDownloadModal").on("show.bs.modal", function () { + self.resetExportInfoUI(); + self.setInstrumentsListReady(); self.instruments.selected = []; self.instruments.dataType = "csv"; - self.resetExportError(); - self.setInstrumentsListReady(); + $(this).find("#patientsInstrumentList label").removeClass("active"); $(this).find("[name='instrument']").prop("checked", false); }); }, - resetExportError: function() { - this.$refs.exportDataLoader.clearExportDataUI(); + resetExportInfoUI: function() { + this.$refs.exportDataLoader.clearInProgress(); + }, + setInProgress: function(inProgress) { + if (!inProgress) { + this.resetExportInfoUI(); + return; + } + this.$refs.exportDataLoader.setInProgress(true); }, initExportEvent: function() { /* @@ -234,7 +247,10 @@ */ let self = this; $("#dataDownloadModal").on("hide.bs.modal", function () { - $("#"+self.getInitElementId()).removeAttr("data-export-in-progress"); + self.setInProgress(false); + }); + $(window).on("focus", function() { + self.handleSetExportHistory(); }); }, setDataType: function (event) { @@ -259,21 +275,100 @@ var queryStringInstruments = (this.instruments.selected).map(item => `instrument_id=${item}`).join("&"); return `/api/patient/assessment?${queryStringInstruments}&format=${this.instruments.dataType}`; }, + getDefaultExportObj: function() { + return { + study: this.currentStudy, + date: new Date().toLocaleString(), + instruments: this.instruments.selected || [] + } + }, + handleInitExport: function(statusUrl) { + if (!statusUrl) return; + // whenever the user initiates an export, we cache the associated celery task URL + this.setCacheTask({ + ...this.getDefaultExportObj(), + url: statusUrl + }); + this.currentTaskUrl = statusUrl; + }, handleAfterExport: function(resultUrl) { //export is done, save the last export to local storage this.setCacheExportedDataInfo(resultUrl); }, + getCacheExportTaskKey: function() { + return `export_data_task_${this.getUserId()}_${this.currentStudy}`; + }, + removeCacheTaskURL: function() { + localStorage.removeItem(this.getCacheExportTaskKey()); + }, + setCacheTask: function(taskObj) { + if (!taskObj) return; + localStorage.setItem(this.getCacheExportTaskKey(), JSON.stringify(taskObj)); + }, + getCacheTask: function() { + const task = localStorage.getItem(this.getCacheExportTaskKey()); + if (!task) return null; + let resultJSON = null; + try { + resultJSON = JSON.parse(task); + } catch(e) { + console.log("Unable to parse task JSON ", e); + resultJSON = null; + } + return resultJSON; + }, + getFinishedStatusURL: function(url) { + if (!url) return ""; + return url.replace("/status", ""); + }, + getExportDataInfoFromTask: function(callback) { + callback = callback || function() {}; + const task = this.getCacheTask(); + const self = this; + if (!task) { + callback({data: null}); + return; + } + const taskURL = task.url; + if (!taskURL) { + callback({data: null}); + return; + } + $.getJSON(taskURL, function(data) { + if (!data) { + callback({data: null}); + return; + } + // check the status of the celery task and returns the data if it had been successful + const exportStatus = String(data["state"]).toUpperCase(); + callback({ + data : + exportStatus === "SUCCESS"? + { + ...task, + url: self.getFinishedStatusURL(taskURL) + }: + null + }); + // callback({ + // data: { + // ...task, + // url: self.getFinishedStatusURL(taskURL) + // } + // }) + }).fail(function() { + callback({data: null}); + }); + }, getCacheExportedDataInfoKey: function() { //uniquely identified by each user and the study - return `exporDataInfo_${this.getUserId()}_${this.currentStudy}}`; + return `exporDataInfo_${this.getUserId()}_${this.currentStudy}`; }, setCacheExportedDataInfo: function(resultUrl) { if (!resultUrl) return false; if (!this.hasInstrumentsSelection()) return; var o = { - study: this.currentStudy, - date: new Date().toLocaleString(), - instruments: this.instruments.selected, + ...this.getDefaultExportObj(), url: resultUrl }; localStorage.setItem(this.getCacheExportedDataInfoKey(), JSON.stringify(o)); @@ -284,7 +379,14 @@ if (!cachedItem) { return null; } - return JSON.parse(cachedItem); + let resultJSON = null; + try { + resultJSON = JSON.parse(cachedItem); + } catch(e) { + console.log("Unable to parse cached data export info ", e); + resultJSON = null; + } + return resultJSON; }, hasExportHistory: function() { return this.exportHistory || this.getCacheExportedDataInfo(); @@ -292,6 +394,27 @@ setExportHistory: function(o) { this.exportHistory = o; }, + handleSetExportHistory: function() { + const self = this; + this.getExportDataInfoFromTask(function(data) { + if (data && data.data) { + this.setExportHistory(data.data); + const task = this.getCacheTask(); + // console.log("current task URL ", self.getFinishedStatusURL(self.currentTaskUrl)); + // console.log("cached task URL ", self.getFinishedStatusURL(task.url)); + if (task && + task.url && + self.getFinishedStatusURL(task.url) === self.getFinishedStatusURL(self.currentTaskUrl)) { + this.setInProgress(false); + } + return; + } + const cachedDataInfo = this.getCacheExportedDataInfo(); + if (cachedDataInfo) { + this.setExportHistory(cachedDataInfo); + } + }.bind(this)); + } } }; diff --git a/portal/static/js/src/components/asyncExportDataLoader.vue b/portal/static/js/src/components/asyncExportDataLoader.vue index f111a238e2..f72827ef02 100644 --- a/portal/static/js/src/components/asyncExportDataLoader.vue +++ b/portal/static/js/src/components/asyncExportDataLoader.vue @@ -87,11 +87,15 @@ clearTimeElapsed: function() { this.exportTimeElapsed = 0; }, - onBeforeExportData: function() { + clearInProgress: function() { this.updateExportProgress("", ""); this.clearExportDataTimeoutID(); this.clearTimeElapsed(); this.clearExportDataUI(); + this.setInProgress(false); + }, + onBeforeExportData: function() { + this.clearInProgress(); this.setInProgress(true); $("#" + this.initElementId).attr("disabled", true); $(".export__display-container").addClass("active"); @@ -103,7 +107,6 @@ }, onAfterExportData: function(options) { options = options || {}; - let delay = options.delay||5000; $("#" + this.initElementId).attr("disabled", false); $(".export__status").removeClass("active"); this.setInProgress(); @@ -129,6 +132,8 @@ url: exportDataUrl, success: function(data, status, request) { let statusUrl= request.getResponseHeader("Location"); + // console.log("status URL ", statusUrl) + self.$emit("initExport", statusUrl); self.updateExportProgress(statusUrl, function(data) { self.onAfterExportData(data); }); @@ -165,7 +170,7 @@ let self = this; let waitTime = 3000; // send GET request to status URL - let rqId = $.getJSON(statusUrl, function(data) { + $.getJSON(statusUrl, function(data) { if (!data) { callback({error: true}); return; @@ -183,12 +188,12 @@ percent = parseInt(data['current'] * 100 / data['total']) + "%"; } else { percent = " -- %"; - //allow maximum allowed elapsed time of pending status and no progress percentage returned, - //if still no progress returned, then return error and display message - if (exportStatus === "PENDING" && self.exportTimeElapsed > self.maximumPendingTime) { - callback({error: true, message: "Processing job not responding. Please try again.", delay: 10000}); - return; - } + } + //allow maximum allowed elapsed time of pending status and no progress percentage returned, + //if still no progress returned, then return error and display message + if (exportStatus === "PENDING" && self.exportTimeElapsed > self.maximumPendingTime) { + callback({error: true, message: "Processing job not responding. Please try again.", delay: 30000}); + return; } //update status and percentage displays self.updateProgressDisplay(exportStatus, percent, true); @@ -199,6 +204,9 @@ setTimeout(function() { window.location.assign(resultUrl); }, 50); //wait a bit before retrieving results + } else { + callback({error: true, message: "Unknown status return from the processing job. Status: ", exportStatus}); + return; } self.updateProgressDisplay(data["state"], ""); setTimeout(function() { @@ -214,7 +222,7 @@ (self.arrExportDataTimeoutID).push(self.exportDataTimeoutID); } }).fail(function() { - callback({error: true}); + callback({error: true, message: "The processing job has failed."}); }); } }