From ae2bc5096cc1bd95a486f0c765449b20579b6a2d Mon Sep 17 00:00:00 2001 From: temi Date: Wed, 14 Jun 2023 09:39:15 +1000 Subject: [PATCH] AtlasOfLivingAustralia/biocollect#1539, AtlasOfLivingAustralia/biocollect#1532 - catches expired JWT token exception - search species when offline now searches all species when first attempt returns no result - isOffline is not overridden --- build.gradle | 2 +- .../javascripts/forms-knockout-bindings.js | 142 +++++- grails-app/assets/javascripts/images.js | 55 ++- grails-app/assets/javascripts/metamodel.js | 217 +++++++++ grails-app/assets/javascripts/utils.js | 24 + grails-app/assets/javascripts/viewModels.js | 416 ++++++++++++++---- .../ala/ecodata/forms/UserInfoService.groovy | 61 +-- 7 files changed, 780 insertions(+), 137 deletions(-) create mode 100644 grails-app/assets/javascripts/metamodel.js diff --git a/build.gradle b/build.gradle index 3c5f5841..5951ebaf 100644 --- a/build.gradle +++ b/build.gradle @@ -18,7 +18,7 @@ plugins { } -version "6.1-COGNITO-SNAPSHOT" +version "6.1-PWA-SNAPSHOT" group "org.grails.plugins" apply plugin:"eclipse" diff --git a/grails-app/assets/javascripts/forms-knockout-bindings.js b/grails-app/assets/javascripts/forms-knockout-bindings.js index ef2dd29c..b09bec92 100644 --- a/grails-app/assets/javascripts/forms-knockout-bindings.js +++ b/grails-app/assets/javascripts/forms-knockout-bindings.js @@ -125,6 +125,11 @@ var progress = ko.observable(); var error = ko.observable(); var complete = ko.observable(true); + var db; + + if (typeof getDB === 'function') { + db = getDB(); + } var config = valueAccessor(); config = $.extend({}, config, defaultConfig); @@ -219,7 +224,27 @@ } }).on(eventPrefix+'fail', function(e, data) { - error(data.errorThrown); + if (isOffline()) { + var file = data.files[0]; + db.file.put(file).then(function(fileId) { + // var data = { + // thumbnailUrl: f.thumbnail_url, + // url: f.url, + // contentType: f.contentType, + // filename: f.name, + // name: f.name, + // filesize: f.size, + // dateTaken: f.isoDate, + // staged: true, + // attribution: f.attribution, + // licence: f.licence + // }; + // + // target.push(new ImageViewModel(data, true, context)); + }); + } else { + error(data.errorThrown); + } }); ko.applyBindingsToDescendants(innerContext, element); @@ -399,6 +424,84 @@ return result; }; + function onlineQuery(url, data) { + return $.ajax({ + url: url, + dataType:'json', + data: data + }); + } + + function offlineQuery(url, data) { + var deferred = $.Deferred() + + if ( typeof URLSearchParams == 'function') { + var paramIndex = url.indexOf('?'), + paramsString = paramIndex > -1 ? url.substring(paramIndex + 1) : url, + params = new URLSearchParams(paramsString), + limit = parseInt(params.get('limit') || "10"), + db = getDB(), + projectActivityId = params.get('projectActivityId'), + dataFieldName = params.get('dataFieldName'), + outputName = params.get('output'); + + db.open().then(function () { + db.taxon.where({'projectActivityId': projectActivityId,'dataFieldName': dataFieldName,'outputName': outputName}) + .count(function (count){ + if (count > 0) { + db.taxon.where({'projectActivityId': projectActivityId,'dataFieldName': dataFieldName,'outputName': outputName}) + .and(function (item) { + if(data.q) { + var query = data.q.toLowerCase(); + return (item.name && item.name.toLowerCase().startsWith(query)) || + (item.scientificName && item.scientificName.toLowerCase().startsWith(query)) || + (item.commonName && item.commonName.toLowerCase().startsWith(query)); + } + else + return true + }) + .limit(limit).toArray() + .then(function (data) { + deferred.resolve({autoCompleteList: data}); + }) + .catch(function (e) { + deferred.reject(e); + }); + } + else { + var promises = [] + promises.push(db.taxon.where('scientificName').startsWithAnyOfIgnoreCase(data.q) + .limit(limit).toArray()); + + promises.push(db.taxon.where('commonName').startsWithAnyOfIgnoreCase(data.q) + .limit(limit).toArray()); + + Promise.all(promises).then(function (responses) { + var data = []; + data.push.apply(data, responses[1]); + data.push.apply(data, responses[0]); + deferred.resolve({autoCompleteList: data}) + }) + } + }); + }); + + return deferred.promise(); + } + + deferred.resolve({autoCompleteList: []}); + return deferred.promise(); + } + + function searchSpecies(url, data) { + if (isOffline()) { + return offlineQuery(url, data); + } + else { + return onlineQuery(url, data); + } + } + options.source = function(request, response) { $(element).addClass("ac_loading"); @@ -409,29 +512,22 @@ if (list) { $.extend(data, {listId: list}); } - $.ajax({ - url: url, - dataType:'json', - data: data, - success: function(data) { - var items = $.map(data.autoCompleteList, function(item) { - return { - label:item.name, - value: item.name, - source: item - } - }); - items = [{label:"Missing or unidentified species", value:request.term, source: {listId:'unmatched', name: request.term}}].concat(items); - response(items); - }, - error: function() { - items = [{label:"Error during species lookup", value:request.term, source: {listId:'error-unmatched', name: request.term}}]; - response(items); - }, - complete: function() { - $(element).removeClass("ac_loading"); - } + searchSpecies(url,data).then(function(data) { + var items = $.map(data.autoCompleteList, function(item) { + return { + label:item.name, + value: item.name, + source: item + } + }); + items = [{label:"Missing or unidentified species", value:request.term, source: {listId:'unmatched', name: request.term}}].concat(items); + response(items); + }).fail(function(e) { + items = [{label:"Error during species lookup", value:request.term, source: {listId:'error-unmatched', name: request.term}}]; + response(items); + }).always(function() { + $(element).removeClass("ac_loading"); }); }; options.select = function(event, ui) { diff --git a/grails-app/assets/javascripts/images.js b/grails-app/assets/javascripts/images.js index 7ffa4802..5a1ce6c4 100644 --- a/grails-app/assets/javascripts/images.js +++ b/grails-app/assets/javascripts/images.js @@ -22,9 +22,9 @@ function ImageViewModel(prop, skipFindingDocument, context){ self.dateTaken = ko.observable(prop.dateTaken || (new Date()).toISOStringNoMillis()).extend({simpleDate:false}); self.contentType = ko.observable(prop.contentType); - self.url = prop.url; + self.url = ko.observable(prop.url); self.filesize = prop.filesize; - self.thumbnailUrl = prop.thumbnailUrl; + self.thumbnailUrl = ko.observable(prop.thumbnailUrl); self.filename = prop.filename; self.attribution = ko.observable(prop.attribution); self.licence = ko.observable(prop.licence); @@ -41,6 +41,7 @@ function ImageViewModel(prop, skipFindingDocument, context){ self.activityId = prop.activityId; self.isEmbargoed = prop.isEmbargoed; self.identifier=prop.identifier; + self.blob = undefined; self.remove = function(images, data, event){ @@ -52,6 +53,21 @@ function ImageViewModel(prop, skipFindingDocument, context){ } } + /** + * any document that is in index db. Their url will be prefixed with blob:. + */ + self.isBlobDocument = function(){ + return !!document.blob; + } + + self.getBlob = function(){ + return document.blobObject; + } + + self.isBlobUrl = function(url){ + return url && url.indexOf('blob:') === 0; + } + self.getActivityLink = function(){ return fcConfig.activityViewUrl + '/' + self.activityId; } @@ -61,7 +77,30 @@ function ImageViewModel(prop, skipFindingDocument, context){ } self.getImageViewerUrl = function(){ - return fcConfig.imageLeafletViewer + '?file=' + encodeURIComponent(self.url); + return fcConfig.imageLeafletViewer + '?file=' + encodeURIComponent(self.url()); + } + + /** + * Check if the url is a valid object url. + */ + self.fetchImage = function() { + if (!isUuid(self.documentId) && !isNaN(self.documentId)) { + var documentId = parseInt(self.documentId); + entities.offlineGetDocument(documentId).then(function(result) { + var doc = result.data; + document = doc; + if (self.isBlobDocument()) { + var url = self.url(); + if (self.isBlobUrl(url)) { + URL.revokeObjectURL(url); + } + + url = ImageViewModel.createObjectURL(doc); + self.url(url); + self.thumbnailUrl(url); + } + }); + } } self.summary = function(){ @@ -75,4 +114,14 @@ function ImageViewModel(prop, skipFindingDocument, context){ message += takenOn; return "

" + self.notes() + '

' + message + ''; } + + self.fetchImage(); +} + +ImageViewModel.createObjectURL = function addObjectURL(document){ + if (document.blob) { + var blob = document.blobObject = new Blob([document.blob], {type: document.contentType}); + var url = URL.createObjectURL(blob); + return url; + } } \ No newline at end of file diff --git a/grails-app/assets/javascripts/metamodel.js b/grails-app/assets/javascripts/metamodel.js new file mode 100644 index 00000000..b3a69b3a --- /dev/null +++ b/grails-app/assets/javascripts/metamodel.js @@ -0,0 +1,217 @@ +function MetaModel(metaModel) { + var self = this; + self.metaModel = metaModel; + + function findDataModelItemByNameInOutputModel(name, context) { + if (!context) { + return ; + } + + return context.forEach(function (node) { + if (node.name === name) { + return node; + } else if (isNestedDataModelType(node)) { + const nested = getNestedDataModelNodes(node); + return findDataModelItemByName(name, nested); + } else { + return null; + } + }); + } + + function getNamesForDataType(type) { + var outputModels = self.metaModel.outputModels, + result = {}; + + for(var name in outputModels) { + var output = outputModels[name]; + + result[name] = getNamesForDataTypeInOutputModel(type, output.dataModel); + } + + return result; + } + + function getNamesForDataTypeInOutputModel(type, context) { + var names = {}; + var childrenNames; + + if (!context) { + return ; + } + + context.forEach(function (data) { + if (isNestedDataModelType(data)) { + // recursive call for nested data model + childrenNames = getNamesForDataTypeInOutputModel(type, getNestedDataModelNodes(data)); + if (Object.keys(childrenNames).length > 0) { + names[data.name] = childrenNames; + } + } + + if (data.dataType === type) { + names[data.name] = true; + } + }); + + return names; + } + + function getDataForType(type, activity) { + if (!activity.outputs || !type) { + return + } + + var pathToData = getNamesForDataType(type), + data = []; + for (var outputName in pathToData) { + var path, result; + var output = findDataByModelName(outputName, activity)[0]; + if(output) { + path = pathToData[outputName]; + result = getData(output, path) + merge(result, data); + } + } + + return data; + } + + /** + * + * @param output + * @param paths - {'foo': true, 'bar': {'baz': true}} + */ + function getData (output, paths) { + var pathsToData = serializePaths(paths); + var data = []; + + pathsToData.forEach(function (path) { + var result = getDataFromPath(path, output); + merge(result, data); + }); + + return data; + } + + function merge(input, output) { + if (Array.isArray(input)) { + output.push.apply(output, input); + } + else { + output.push(input); + } + + return output; + } + + function serializePaths(obj) { + var paths = []; + + function traverse(obj, currentPath) { + for (var key in obj) { + if (obj.hasOwnProperty(key)) { + var value = obj[key]; + var newPath = currentPath.concat(key); + + if (value === true) { + paths.push(newPath); + } else if (typeof value === 'object' && value !== null) { + traverse(value, newPath); + } + } + } + } + + traverse(obj, ['data']); + return paths; + } + + function getDataFromPath(path, output) { + var temp = output; + var result = []; + var navigatedPath = []; + + if (path) { + path.forEach(function(prop) { + if (Array.isArray(temp)) { + temp.forEach(function(map) { + result.push.apply(result, getDataFromPath(path.filter(function(p) { + return !navigatedPath.includes(p); + }), map)); + }); + + temp = null; + } else { + temp = temp[prop]; + } + + navigatedPath.push(prop); + }); + } + + if (temp !== null) { + if (temp instanceof Array) { + result.push.apply(result, temp); + } else { + result.push(temp); + } + } + + return result; + } + + function findDataByModelName(name, activity) { + return $.grep(activity.outputs || [], function (output) { return output.name == name;}) + } + + function isNestedDataModelType(node) { + return Array.isArray(node.columns) && node.dataType !== "geoMap"; + } + + function getNestedDataModelNodes(node) { + return node.columns; + } + + function updateDataForSources (sourceNames, activity, dataToUpdate) { + var outputModels = self.metaModel.outputModels; + for (var name in outputModels) { + var sourceNamesForOutput = sourceNames[name], + outputData = findDataByModelName(name, activity)[0]; + + updateDataForSourcesInOutput(sourceNamesForOutput, outputData.data, dataToUpdate); + } + } + + function updateDataForSourcesInOutput(sourceNamesForOutput, outputData, data) { + var childrenNames; + if (!sourceNamesForOutput) { + return ; + } + + for (var name in sourceNamesForOutput) { + childrenNames = sourceNamesForOutput[name]; + if (childrenNames === true) { + outputData[name] = data; + } + else if (typeof childrenNames === 'object') { + if (Array.isArray(outputData[name])) { + outputData[name].forEach(function (item) { + updateDataForSourcesInOutput(childrenNames, item, data); + }); + } + else { + updateDataForSourcesInOutput(childrenNames, outputData[name], data); + } + } + } + } + + + + return { + getDataForType: getDataForType, + getNamesForDataType: getNamesForDataType, + updateDataForSources: updateDataForSources + } +} \ No newline at end of file diff --git a/grails-app/assets/javascripts/utils.js b/grails-app/assets/javascripts/utils.js index e8871361..7aea3978 100644 --- a/grails-app/assets/javascripts/utils.js +++ b/grails-app/assets/javascripts/utils.js @@ -229,3 +229,27 @@ function resolveSites(sites, addNotFoundSite) { return resolved; } + +/** + * Checks if the provided identifier matches the regex pattern for a UUID. + * @see UUID_ONLY_REGEX + * + * @param id the id to check + * @returns {boolean} + */ +var UUID_ONLY_REGEX = new RegExp("^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", "i"); +function isUuid(id) { + var uuid = false; + + if (id && id != null && typeof id === "string") { + var match = id.match(UUID_ONLY_REGEX); + uuid = match != null && match.length > 0; + } + + return uuid; +} + +function isOffline() { + var forceOffline = false; + return !navigator.onLine || forceOffline; +} \ No newline at end of file diff --git a/grails-app/assets/javascripts/viewModels.js b/grails-app/assets/javascripts/viewModels.js index 41803053..f1c712d5 100644 --- a/grails-app/assets/javascripts/viewModels.js +++ b/grails-app/assets/javascripts/viewModels.js @@ -40,6 +40,7 @@ function enmapify(args) { context = args.context, uniqueNameUrl = (activityLevelData.uniqueNameUrl || args.uniqueNameUrl) + "/" + ( activityLevelData.pActivity.projectActivityId || activityLevelData.pActivity.projectId), projectId = activityLevelData.pActivity.projectId, + projectActivityId = activityLevelData.pActivity.projectActivityId, // hideSiteSelection is now dependent on survey's mapConfiguration // check viewModel.transients.hideSiteSelection project = args.activityLevelData.project || {}, @@ -63,7 +64,7 @@ function enmapify(args) { centroidLatObservable = container[name + "CentroidLatitude"] = ko.observable(), centroidLonObservable = container[name + "CentroidLongitude"] = ko.observable(), //siteObservable filters out all private sites - sitesObservable = ko.observableArray(resolveSites(mapConfiguration.sites)), + sitesObservable = ko.observableArray(), //container[SitesArray] does not care about 'private' or not, only check if the site matches the survey configs surveySupportedSitesObservable = container[name + "SitesArray"] = ko.computed(function(){ return sitesObservable(); @@ -90,15 +91,29 @@ function enmapify(args) { } return {validation:true}; - }; + }, + db; viewModel.mapElementId = name + "Map"; - + activityLevelData.UTILS = { + getProjectActivitySites: function () { + if (isOffline()) { + return offlineGetProjectActivitySites(); + } + else { + return onlineGetProjectActivitySites(); + } + } + }; // add event handling functions if(!viewModel.on){ new Emitter(viewModel); } + if (typeof getDB === 'function') { + db = getDB(); + } + viewModel.transients = viewModel.transients || {}; var latObservableStaged = viewModel.transients[name + "LatitudeStaged"] = ko.observable(), lonObservableStaged = viewModel.transients[name + "LongitudeStaged"] = ko.observable(), @@ -168,6 +183,14 @@ function enmapify(args) { } function canAddPointToMap (lat, lng, callback) { + if (isOffline()) { + offlineCanAddPointToMap(lat, lng, callback); + } else { + onlineCanAddPointToMap(lat, lng, callback); + } + } + + function onlineCanAddPointToMap(lat, lng, callback) { var url = checkPointUrl + '?lat=' + lat + '&lng=' + lng + '&projectId=' + projectId; showLoadingOnCoordinateCheck(true); $.ajax({ @@ -188,6 +211,10 @@ function enmapify(args) { }); } + function offlineCanAddPointToMap(lat, lng, callback) { + callback({isPointInsideProjectArea: true}); + } + viewModel.transients.hideSiteSelection = ko.computed(function () { if (mapConfiguration && ([SITE_PICK, SITE_PICK_CREATE].indexOf(mapConfiguration.surveySiteOption) >= 0)) { return true; @@ -474,33 +501,27 @@ function enmapify(args) { })[0]; //search from site collection in case it is a private site if (!matchingSite){ - var siteUrl = getSiteUrl + '/' + siteId + ".json" - //It is a sync call - $.ajax({ - type: "GET", - url: siteUrl, - async: false, - success: function (data) { - if (data.site){ - var geoType = data.site.extent.source; - data.site.name='Location of the sighting'; - sitesObservable.push(data.site); - matchingSite = data.site; - map.clearBoundLimits(); - map.setGeoJSON(Biocollect.MapUtilities.featureToValidGeoJson(matchingSite.extent.geometry)); - } - }, - error: function(xhr) { - console.log(xhr); + fetchSite(siteId).done(function (result) { + if (result.data) { + var site = result.data; + site.name='Location of the sighting'; + sitesObservable.push(site); + matchingSite = site; + map.clearBoundLimits(); + map.setGeoJSON(Biocollect.MapUtilities.featureToValidGeoJson(matchingSite.extent.geometry)); } + }).fail(function(result) { + console.log(result.message); }); } + // TODO: OPTIMISE THE PROCEDUE - if (matchingSite) { + else if (matchingSite) { console.log("Clearing map before displaying a new shape") map.clearBoundLimits(); map.setGeoJSON(Biocollect.MapUtilities.featureToValidGeoJson(matchingSite.extent.geometry)); } + } else { // Keep the previous code to make compatible with old records // Can be removed after all data be migrated. @@ -515,10 +536,67 @@ function enmapify(args) { } } - function getProjectArea() { - return $.grep(activityLevelData.pActivity.sites, function (item) { - return item.name.indexOf('Project area for') == 0 + function fetchSite(siteId) { + if (isOffline()) { + return offlineFetchSite(siteId); + } else { + return onlineFetchSite(siteId); + } + } + + function onlineFetchSite(siteId) { + var siteUrl = getSiteUrl + '/' + siteId + ".json?view=brief", + deferred = $.Deferred(); + $.ajax({ + url: siteUrl, + success: function (data) { + if (data.site) { + deferred.resolve({message: "Found site", success: true, data: data.site}); + } + else { + deferred.reject({message: "Could not find site", success: false}); + } + }, + error: function () { + deferred.reject({message: "Failed to fetch site from server", success: false, arguments: arguments}); + } + }); + + return deferred.promise() + } + + function offlineFetchSite(siteId) { + var deferred = $.Deferred(); + + if (db) { + db.site.where('siteId').equals(siteId).first().then(function (site) { + deferred.resolve({message: "Failed to fetch site form db", success: false, data: site}); + }).catch(function (error) { + deferred.reject({message: "Failed to fetch site form db", success: false, arguments: arguments}); + }); + } else { + deferred.reject({message: "No offline database available", success: false}); + } + + return deferred.promise(); + } + + function offlineGetProjectActivitySites () { + var deferred = $.Deferred(); + db.projectActivity.where('projectActivityId').equals(projectActivityId).first().then(function (projectActivity) { + deferred.resolve({message: "Found project activity", success: true, data: projectActivity.sites}); + }).catch(function (){ + deferred.reject({message: "Failed to get project activity", success: false}); }); + + return deferred.promise() + } + + function onlineGetProjectActivitySites () { + var deferred = $.Deferred(); + var sites = activityLevelData.pActivity.sites || []; + deferred.resolve({message: "Found sites", success: true, data: sites}); + return deferred.promise(); } function createGeoJSON(geoJSON, layerOptions) { @@ -667,7 +745,7 @@ function enmapify(args) { subscribeOrDisposeSiteIdObservable(false); siteIdObservable(null); Biocollect.Modals.showModal({ - viewModel: new AddSiteViewModel(uniqueNameUrl) + viewModel: new AddSiteViewModel(uniqueNameUrl, activityLevelData) }).then(function (newSite) { loadingObservable(true); var extent = convertGeoJSONToExtent(map.getGeoJSON()); @@ -681,20 +759,21 @@ function enmapify(args) { ], extent: extent } - }).then(function (data, jqXHR, textStatus) { + }) + .then(function (data, jqXHR, textStatus) { return reloadSiteData().then(function () { return data.id }) }) - .done(function (id) { - siteIdObservable(id); - }) - .fail(saveSiteFailed) - .always(function () { - $.unblockUI(); - loadingObservable(false); - subscribeOrDisposeSiteIdObservable(true); - }); + .then(function (id) { + siteIdObservable(id); + }) + .catch(saveSiteFailed) + .always(function () { + $.unblockUI(); + loadingObservable(false); + subscribeOrDisposeSiteIdObservable(true); + }); }).fail(function(){ enableEditMode() subscribeOrDisposeSiteIdObservable(true); @@ -732,32 +811,32 @@ function enmapify(args) { blockUIWithMessage("Updating, please stand by..."); addSite({ - pActivityId: activityLevelData.pActivity.projectActivityId, - site: site} - ).then(function (data, jqXHR, textStatus) { - var anonymousSiteId= data.id; - //IMPORTANT - //sites is a data-bind source for the selection dropdown list and bound to activity-output-data-location - //if the new created site id is not in this list, then the location would be empty - var geometryType = extent.geometry.type; - var anonymousSite = { - name: 'The '+ geometryType + ' you created.', - siteId: anonymousSiteId, - extent: extent, - visibility: "private" - } - sitesObservable.remove(function(site){ - return site.visibility == 'private'; - }) - sitesObservable.push(anonymousSite); - siteIdObservable(anonymousSiteId); - }) - .always(function () { - $.unblockUI(); - loadingObservable(false); - subscribeOrDisposeSiteIdObservable(true); + pActivityId: activityLevelData.pActivity.projectActivityId, + site: site + }).then(function (data, jqXHR, textStatus) { + var anonymousSiteId= data.id; + //IMPORTANT + //sites is a data-bind source for the selection dropdown list and bound to activity-output-data-location + //if the new created site id is not in this list, then the location would be empty + var geometryType = extent.geometry.type; + var anonymousSite = { + name: 'The '+ geometryType + ' you created.', + siteId: anonymousSiteId, + extent: extent, + visibility: "private" + } + sitesObservable.remove(function(site){ + return site.visibility == 'private'; }) - .fail(saveSiteFailed) + sitesObservable.push(anonymousSite); + siteIdObservable(anonymousSiteId); + }) + .always(function () { + $.unblockUI(); + loadingObservable(false); + subscribeOrDisposeSiteIdObservable(true); + }) + .fail(saveSiteFailed) } /** @@ -781,6 +860,14 @@ function enmapify(args) { } function addSite(site) { + if (isOffline()) { + return offlineAddSite(site); + } else { + return onlineAddSite(site); + } + } + + function onlineAddSite(site) { var siteId = site['site'].siteId site['site']['asyncUpdate'] = true; // aysnc update Metadata service for performance improvement @@ -793,6 +880,76 @@ function enmapify(args) { }); } + function offlineAddSite (data) { + var site = data.site, + projectId = data.projectId, + projectActivityId = data.pActivityId; + + if (projectActivityId) { + return offlineAddSiteToProjectActivity(site, projectActivityId); + } + // todo : add site to project + // else if(projectId) { + // return offlineAddSiteToProject(site, projectId); + // } + } + + function offlineAddSiteToProjectActivity (site, projectActivityId) { + var deferred = $.Deferred(); + + site.entityUpdated = true; + offlineSaveSite(site).then(function (result) { + var siteId = result.data; + offlineAddSiteIdToProjectActivity(siteId, projectActivityId).then(function () { + // adding id to resolve parameter to be consistent with result returned from ajax call + deferred.resolve({message: "Site and project activity saved offline.", success: true, data: siteId, id: siteId}); + }).fail(function (e) { + deferred.reject({message: "Site failed to save offline.", success: false}); + }); + }); + + return deferred.promise(); + } + + function offlineSaveSite(site) { + var deferred = $.Deferred(); + db.site.put(site).then(function (id) { + deferred.resolve({message: "Site saved to db.", success: true, data: id}); + }).catch(function (){ + deferred.reject({message: "Site failed to save offline.", success: false}); + }); + + return deferred.promise(); + }; + + function offlineSaveProjectActivity (pa) { + var deferred = $.Deferred(); + + db.table('projectActivity').put(pa).then(function (id) { + deferred.resolve({message: "Project activity saved offline.", success: true, data: id}); + }).catch(function (e) { + deferred.reject({message: "Project activity failed to save offline.", success: false}); + }); + + return deferred.promise(); + } + + function offlineAddSiteIdToProjectActivity (siteId, pActivityId) { + var deferred = $.Deferred(); + + db.table('projectActivity').where('projectActivityId').equals(pActivityId).first().then(function (pActivity) { + pActivity.sites = pActivity.sites || []; + pActivity.sites.push(siteId); + offlineSaveProjectActivity(pActivity).then(function () { + deferred.resolve({message: "Project activity updated offline.", success: true, data: pActivityId}); + }).fail(function (e) { + deferred.reject({message: "Failed to update project activity - " + pActivityId, success: false}); + }); + }); + + return deferred.promise(); + } + function saveSiteFailed(jqXHR, textStatus, errorThrown) { var errorMessage = jqXHR.responseText || "An error occured while attempting to save the site."; bootbox.alert(errorMessage); @@ -898,12 +1055,47 @@ function enmapify(args) { } function reloadSiteData() { + if(isOffline()) { + return offlineReloadSiteData(); + } else { + return onlineReloadSiteData(); + } + } + + function onlineReloadSiteData() { var entityType = activityLevelData.pActivity.projectActivityId ? "projectActivity" : "project" return $.getJSON(listSitesUrl + '/' + (activityLevelData.pActivity.projectActivityId || activityLevelData.pActivity.projectId) + "?entityType=" + entityType).then(function (data, textStatus, jqXHR) { sitesObservable(data); }); } + function offlineReloadSiteData() { + var deferred = $.Deferred(); + var entityType = activityLevelData.pActivity.projectActivityId ? "projectActivity" : "project" + switch (entityType) { + case "projectActivity": + activityLevelData.UTILS.getProjectActivitySites().then(function (result) { + var siteIds = result.data; + db.site.where("siteId").anyOf(siteIds).toArray(function (sites) { + sitesObservable(sites); + deferred.resolve({message: "Sites retrieved from db.", success: true, data: sites}); + }).catch(function (error) { + deferred.reject({message: "An error occurred while retrieving sites from db.", success: false}); + }); + }); + break; + case "project": + db.site.where("projects").equals(projectId).toArray(function (sites) { + sitesObservable(sites); + deferred.resolve({message: "Sites retrieved from db.", success: true, data: sites}); + }).catch(function (error) { + deferred.reject({message: "An error occurred while retrieving sites from db.", success: false}); + }); + break; + } + return deferred.promise(); + } + loadingObservable.subscribe(function (value) { value ? map.startLoading() : map.finishLoading(); }); @@ -911,30 +1103,33 @@ function enmapify(args) { function zoomToDefaultSite(){ var defaultZoomArea = project.projectSiteId; if (!siteIdObservable()){ - var defaultsite = $.grep(activityLevelData.pActivity.sites,function(site){ - if(site.siteId == defaultZoomArea) - return site; - }); - // Is zoom area a project area? - if( (defaultsite.length == 0) && (defaultZoomArea == project.projectSiteId) && project.sites) { - defaultsite = $.grep(project.sites,function(site){ + activityLevelData.UTILS.getProjectActivitySites().then(function (result) { + var sites = result.data; + var defaultsite = $.grep(sites, function(site){ if(site.siteId == defaultZoomArea) return site; }); - } - - var geojson; - if (defaultsite.length>0) { - geojson = Biocollect.MapUtilities.featureToValidGeoJson(defaultsite[0].extent.geometry); - map.clearBoundLimits(); - map.fitToBoundsOf(geojson); - return defaultsite[0].siteId; - } + // Is zoom area a project area? + if ((defaultsite.length == 0) && (defaultZoomArea == project.projectSiteId) && project.sites) { + defaultsite = $.grep(project.sites,function(site){ + if(site.siteId == defaultZoomArea) + return site; + }); + } + var geojson; + if (defaultsite.length>0) { + geojson = Biocollect.MapUtilities.featureToValidGeoJson(defaultsite[0].extent.geometry); + map.clearBoundLimits(); + map.fitToBoundsOf(geojson); + return defaultsite[0].siteId; + } + }) } } - zoomToDefaultSite(); + // fetch sites associated and load to sitesObservable + reloadSiteData().then(zoomToDefaultSite); // Redraw map since it was created on a hidden element. $(validationContainer).on('knockout-visible', function () { @@ -958,7 +1153,7 @@ function enmapify(args) { }; } -var AddSiteViewModel = function (uniqueNameUrl) { +var AddSiteViewModel = function (uniqueNameUrl, activityLevelData) { var self = this; self.uniqueNameUrl = uniqueNameUrl; @@ -966,6 +1161,8 @@ var AddSiteViewModel = function (uniqueNameUrl) { self.name = ko.observable(); self.throttledName = ko.computed(this.name).extend({throttle: 400}); self.nameStatus = ko.observable(AddSiteViewModel.NAME_STATUS.BLANK); + self.db = getDB(); + self.activityLevelData = activityLevelData; self.name.subscribe(function (name) { self.precheckUniqueName(name); @@ -999,7 +1196,7 @@ AddSiteViewModel.prototype.cancel = function () { }; AddSiteViewModel.prototype.precheckUniqueName = function (name) { - if (this.inflight) this.inflight.abort(); + if (this.inflight) this.inflight.abort && this.inflight.abort(); this.nameStatus(name === '' ? AddSiteViewModel.NAME_STATUS.BLANK : AddSiteViewModel.NAME_STATUS.CHECKING); }; @@ -1009,10 +1206,12 @@ AddSiteViewModel.prototype.checkUniqueName = function (name) { if (name === '') return; - self.inflight = $.getJSON(self.uniqueNameUrl + "?name=" + encodeURIComponent(name) + "&entityType=" + (activityLevelData.pActivity.projectActivityId ? "projectActivity" : "project")) - .done(function (data, textStatus, jqXHR) { + self.inflight = siteNameCheck(); + self.inflight + .then(function (data, textStatus, jqXHR) { self.nameStatus(AddSiteViewModel.NAME_STATUS.OK); - }).fail(function (jqXHR, textStatus, errorThrown) { + }) + .catch(function (jqXHR, textStatus, errorThrown) { if (errorThrown === 'abort') { console.log('abort'); return; @@ -1024,12 +1223,61 @@ AddSiteViewModel.prototype.checkUniqueName = function (name) { break; default: self.nameStatus(AddSiteViewModel.NAME_STATUS.ERROR); - bootbox.alert("An error occured checking your name."); + bootbox.alert("An error occurred checking site name."); console.error("Error checking unique status", jqXHR, textStatus, errorThrown); } }); + function siteNameCheck() { + var forceOffline = true; + if (isOffline()) { + return offlineSiteNameCheck() + } else { + return onlineSiteNameCheck() + } + } + + function onlineSiteNameCheck() { + return $.getJSON(self.uniqueNameUrl + "?name=" + encodeURIComponent(name) + "&entityType=" + (activityLevelData.pActivity.projectActivityId ? "projectActivity" : "project")) + } + + function offlineSiteNameCheck () { + + var deferred = $.Deferred(), + entityType = self.activityLevelData.pActivity.projectActivityId ? "projectActivity" : "project"; + + switch (entityType) { + default: + case "projectActivity": + self.db.site.where("projects").anyOf(activityLevelData.pActivity.projectId).and(function (site) { + return site.name === name; + }).count(countHandler); + break; + + case "project": + self.db.site.where("projects").anyOf(activityLevelData.pActivity.projectId).and(function (site) { + return site.name === name; + }).count(countHandler); + break; + } + + function countHandler(count) { + count > 0 ? deferred.reject({status: 409}) : deferred.resolve(); + } + + return deferred.promise(); + } }; +AddSiteViewModel.UTILS = { + getSiteIds : function (sites) { + var siteIds = []; + sites.forEach(function(site){ + siteIds.push(site.siteId); + }); + + return siteIds; +}}; + function validator_site_check(field, rules, i, options){ field = field && field[0]; var model = ko.dataFor(field); diff --git a/grails-app/services/au/org/ala/ecodata/forms/UserInfoService.groovy b/grails-app/services/au/org/ala/ecodata/forms/UserInfoService.groovy index 2af5a199..275a9211 100644 --- a/grails-app/services/au/org/ala/ecodata/forms/UserInfoService.groovy +++ b/grails-app/services/au/org/ala/ecodata/forms/UserInfoService.groovy @@ -106,25 +106,30 @@ class UserInfoService { UserDetails getUserFromJWT(String authorizationHeader = null) { if((config == null) || (alaOidcClient == null)) return - - GrailsWebRequest grailsWebRequest = GrailsWebRequest.lookup() - HttpServletRequest request = grailsWebRequest.getCurrentRequest() - HttpServletResponse response = grailsWebRequest.getCurrentResponse() - if (!authorizationHeader) - authorizationHeader = request?.getHeader(AUTHORIZATION_HEADER_FIELD) - if (authorizationHeader?.startsWith("Bearer")) { - final WebContext context = FindBest.webContextFactory(null, config, JEEContextFactory.INSTANCE).newContext(request, response) - def optCredentials = alaOidcClient.getCredentials(context, config.sessionStore) - if (optCredentials.isPresent()) { - Credentials credentials = optCredentials.get() - def optUserProfile = alaOidcClient.getUserProfile(credentials, context, config.sessionStore) - if (optUserProfile.isPresent()) { - def userProfile = optUserProfile.get() - if(userProfile.userId) { - return authService.getUserForUserId(userProfile.userId) + try { + GrailsWebRequest grailsWebRequest = GrailsWebRequest.lookup() + HttpServletRequest request = grailsWebRequest.getCurrentRequest() + HttpServletResponse response = grailsWebRequest.getCurrentResponse() + if (!authorizationHeader) + authorizationHeader = request?.getHeader(AUTHORIZATION_HEADER_FIELD) + if (authorizationHeader?.startsWith("Bearer")) { + final WebContext context = FindBest.webContextFactory(null, config, JEEContextFactory.INSTANCE).newContext(request, response) + def optCredentials = alaOidcClient.getCredentials(context, config.sessionStore) + if (optCredentials.isPresent()) { + Credentials credentials = optCredentials.get() + def optUserProfile = alaOidcClient.getUserProfile(credentials, context, config.sessionStore) + if (optUserProfile.isPresent()) { + def userProfile = optUserProfile.get() + String userId = userProfile.userId ?: userProfile.getAttribute('username') + if (userId) { + return authService.getUserForUserId(userId) + } } } } + } catch (Throwable e) { + log.error("Failed to get user details from JWT", e) + return } } @@ -142,17 +147,21 @@ class UserInfoService { // Second, check if request has headers to lookup user details. if (!user) { - GrailsWebRequest request = GrailsWebRequest.lookup() - if (request) { - String authorizationHeader = request?.getHeader(AUTHORIZATION_HEADER_FIELD) - String username = request.getHeader(UserInfoService.USER_NAME_HEADER_FIELD) - String key = request.getHeader(UserInfoService.AUTH_KEY_HEADER_FIELD) - - if (authorizationHeader) { - user = getUserFromJWT(authorizationHeader) - } else if (grailsApplication.config.getProperty("mobile.authKeyEnabled", Boolean) && username && key) { - user = getUserFromAuthKey(username, key) + try { + GrailsWebRequest request = GrailsWebRequest.lookup() + if (request) { + String authorizationHeader = request?.getHeader(AUTHORIZATION_HEADER_FIELD) + String username = request.getHeader(UserInfoService.USER_NAME_HEADER_FIELD) + String key = request.getHeader(UserInfoService.AUTH_KEY_HEADER_FIELD) + + if (authorizationHeader) { + user = getUserFromJWT(authorizationHeader) + } else if (grailsApplication.config.getProperty("mobile.authKeyEnabled", Boolean) && username && key) { + user = getUserFromAuthKey(username, key) + } } + } catch (Throwable e) { + log.error("Failed to get user details from JWT or API key", e) } }