From 9640e0f4965412482438e094a6ae4fc2b7ebd38f Mon Sep 17 00:00:00 2001 From: Don Date: Mon, 31 May 2021 22:22:41 +1000 Subject: [PATCH] sequence search : button in output tab to add dataset sequence-search.js : declare and comment viewDatasetFlag. blast-results.js : use upload-base and upload-table. add columnsKeyString, c_name, c_chr, c_pos. add services store, auth, classNames:blast-results. add viewDatasetFlag. add didReceiveAttrs() : initialise selectedParent, namespace. add validateData() app.scss : .blast-results : margin. sequence-search.hbs : pass to blast-results : datasets, refreshDatasets, viewDataset. blast-results.hbs : from data-csv : add message fields, and inputs : selectedDataset, selectedParent (currently read-only), namespace. add checkbox viewDatasetFlag, submit button. add upload-base.js, factored from data-base.js add upload-table.js, factored from data-csv.js --- .../app/components/panel/sequence-search.js | 2 + .../components/panel/upload/blast-results.js | 116 ++++++++++++- frontend/app/styles/app.scss | 4 + .../components/panel/sequence-search.hbs | 5 +- .../components/panel/upload/blast-results.hbs | 85 +++++++++ frontend/app/utils/panel/upload-base.js | 79 +++++++++ frontend/app/utils/panel/upload-table.js | 161 ++++++++++++++++++ 7 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 frontend/app/utils/panel/upload-base.js create mode 100644 frontend/app/utils/panel/upload-table.js diff --git a/frontend/app/components/panel/sequence-search.js b/frontend/app/components/panel/sequence-search.js index 2cf3f7577..9d68b9962 100644 --- a/frontend/app/components/panel/sequence-search.js +++ b/frontend/app/components/panel/sequence-search.js @@ -17,6 +17,8 @@ export default Component.extend({ resultRows : 50, /** true means add / upload result to db as a Dataset */ addDataset : false, + /** true means view the blocks of the dataset after it is added. */ + viewDatasetFlag : false, classNames: ['col-xs-12'], diff --git a/frontend/app/components/panel/upload/blast-results.js b/frontend/app/components/panel/upload/blast-results.js index b151db864..727c60970 100644 --- a/frontend/app/components/panel/upload/blast-results.js +++ b/frontend/app/components/panel/upload/blast-results.js @@ -1,35 +1,102 @@ import Component from '@ember/component'; import { observer, computed } from '@ember/object'; +import { alias } from '@ember/object/computed'; import { inject as service } from '@ember/service'; import { later as run_later } from '@ember/runloop'; - import config from '../../../config/environment'; +import uploadBase from '../../../utils/panel/upload-base'; +import uploadTable from '../../../utils/panel/upload-table'; + const dLog = console.debug; /* global Handsontable */ /* global $ */ +/*----------------------------------------------------------------------------*/ + +/** + * based on backend/scripts/dnaSequenceSearch.bash : columnsKeyString + * This also aligns with createTable() : colHeaders below. + */ +const columnsKeyString = [ + 'name', 'chr', 'pcIdentity', 'lengthOfHspHit', 'numMismatches', 'numGaps', 'queryStart', 'queryEnd', 'pos', 'end' +]; +const c_name = 0, c_chr = 1, c_pos = 8; + +/*----------------------------------------------------------------------------*/ + + /** Display a table of results from sequence-search API request * /Feature/dnaSequenceSearch */ export default Component.extend({ apiServers: service(), blockService : service('data/block'), + /** Similar comment to data-csv.js applies re. store (user could select server via GUI). + * store is used by upload-table.js : getDatasetId() and submitFile() + */ + store : alias('apiServers.primaryServer.store'), + auth: service('auth'), + + + classNames: ['blast-results'], /** true enables display of the search inputs. */ showSearch : false, + /** true means view the blocks of the dataset after it is added. */ + viewDatasetFlag : false, - /** copied from data-base.js, not used yet */ + /*--------------------------------------------------------------------------*/ + + /** copied from data-base.js. for uploadBase*/ isProcessing: false, successMessage: null, errorMessage: null, warningMessage: null, progressMsg: '', + + setProcessing : uploadBase.setProcessing, + setSuccess : uploadBase.setSuccess, + setError : uploadBase.setError, + setWarning : uploadBase.setWarning, + clearMsgs : uploadBase.clearMsgs, + + /** data-csv.js : scrollToTop() scrolls up #left-panel-upload, but that is not required here. */ + scrollToTop() { + }, + + updateProgress : uploadBase.updateProgress, + + + /*--------------------------------------------------------------------------*/ + + /** copied from data-base.js, for uploadTable */ + + selectedDataset: 'new', + newDatasetName: '', + nameWarning: null, + selectedParent: '', + dataType: 'linear', + namespace: '', + + getDatasetId : uploadTable.getDatasetId, + isDupName : uploadTable.isDupName, + onNameChange : observer('newDatasetName', uploadTable.onNameChange), + onSelectChange : observer('selectedDataset', 'selectedParent', uploadTable.onSelectChange), + + /*--------------------------------------------------------------------------*/ + + actions : { + submitFile : uploadTable.submitFile + }, + + /*--------------------------------------------------------------------------*/ + dataMatrix : computed('data.[]', function () { let cells = this.get('data').map((r) => r.split('\t')); return cells; @@ -45,6 +112,12 @@ export default Component.extend({ // this.showTable(); }, + didReceiveAttrs() { + this._super(...arguments); + this.set('selectedParent', this.get('search.parent')); + this.set('namespace', this.get('search.parent') + ':blast'); + }, + /*--------------------------------------------------------------------------*/ /** Outcomes of the API search request. @@ -205,5 +278,44 @@ query ID, subject ID, % identity, length of HSP (hit), # mismatches, # gaps, que table.updateSettings({data:[]}); }, + /*--------------------------------------------------------------------------*/ + + /** called by upload-table.js : onSelectChange() + * No validation of user input is required because table content is output from blast process. + */ + checkBlocks() { + }, + + + /** upload-table.js : submitFile() expects this function. + * In blast-results, the data is not user input so validation is not required. + */ + validateData() { + /** based on data-csv.js : validateData(), which uses table.getSourceData(); + * in this case sourceData is based on .dataMatrix instead + * of going via the table. + */ + return new Promise((resolve, reject) => { + let table = this.get('table'); + if (table === null) { + resolve([]); + } + let sourceData = this.get('dataMatrix'); + /** the last row is empty. */ + let validatedData = sourceData + .filter((row) => (row[c_name] !== '') && (row[c_chr])) + .map((row) => + ({ + name: row[c_name], + // blast output chromosome is e.g. 'chr2A'; Pretzel uses simply '2A'. + block: row[c_chr].replace(/^chr/,''), + // Make sure val is a number, not a string. + val: Number(row[c_pos]) + }) ); + resolve(validatedData); + }); + } + + /*--------------------------------------------------------------------------*/ }); diff --git a/frontend/app/styles/app.scss b/frontend/app/styles/app.scss index f7d8df068..f7ed557f2 100644 --- a/frontend/app/styles/app.scss +++ b/frontend/app/styles/app.scss @@ -1485,6 +1485,10 @@ div#left-panel-upload select margin-bottom: 5px; } +.blast-results { + margin: 1em; +} + .feature-list.active-input > div > div > div.from-input > a { background-color : #e8e8f7; } diff --git a/frontend/app/templates/components/panel/sequence-search.hbs b/frontend/app/templates/components/panel/sequence-search.hbs index 17f31dc97..550e053cc 100644 --- a/frontend/app/templates/components/panel/sequence-search.hbs +++ b/frontend/app/templates/components/panel/sequence-search.hbs @@ -123,9 +123,12 @@ AGCTGGGTGTCGTTGATCTTCAGGTCCTTCTGGATGTACAGCGACGCTCC" }} {{#each this.searches as |search|}} - + {{panel/upload/blast-results search=search + datasets=datasets + refreshDatasets=refreshDatasets + viewDataset=viewDataset active=(bs-eq tab.activeId search.tabId ) }} diff --git a/frontend/app/templates/components/panel/upload/blast-results.hbs b/frontend/app/templates/components/panel/upload/blast-results.hbs index d6a326fc7..4ee686cde 100644 --- a/frontend/app/templates/components/panel/upload/blast-results.hbs +++ b/frontend/app/templates/components/panel/upload/blast-results.hbs @@ -29,6 +29,16 @@ {{!-- --------------------------------------------------------------------- --}} +{{!-- from data-csv --}} + +{{elem/panel-message + successMessage=successMessage + warningMessage=warningMessage + errorMessage=errorMessage}} +{{#if nameWarning}} +{{elem/panel-message + warningMessage=nameWarning}} +{{/if}} {{#if isProcessing}} {{#elem/panel-form @@ -38,5 +48,80 @@ {{/elem/panel-form}} {{/if}} + + +
+{{#if (eq selectedDataset 'new')}} +
+ {{input + id="dataset_new" + type="text" + value=newDatasetName + class="form-control" + placeholder="New dataset name..." + disabled=isProcessing + }} + +
+ + {{selectedParent}} + {{!-- + + --}} + +
+ + +
+ + {{input id="namespace" type="text" value=namespace disabled=isProcessing }} +
+{{/if}} + +{{!-- --------------------------------------------------------------------- --}} + + + {{input type="checkbox" name="viewDatasetFlag" checked=viewDatasetFlag }} + + + + +
+ + + +
+ +{{!-- --------------------------------------------------------------------- --}} +
diff --git a/frontend/app/utils/panel/upload-base.js b/frontend/app/utils/panel/upload-base.js new file mode 100644 index 000000000..dd7261404 --- /dev/null +++ b/frontend/app/utils/panel/upload-base.js @@ -0,0 +1,79 @@ + +/** + * factored from components/panel/upload/data-base.js + * May evolve this to a decorator or a sub-component. + * + * usage : + * + // file: null, // not required + isProcessing: false, + successMessage: null, + errorMessage: null, + warningMessage: null, + progressMsg: '', + + */ + +export default { + + + setProcessing() { + this.updateProgress(0, 'up'); + this.setProperties({ + isProcessing: true, + successMessage: null, + errorMessage: null, + warningMessage: null + }); + }, + setSuccess(msg) { + let response = msg ? msg : 'Uploaded successfully'; + /** .file is undefined (null) when data is read from table instead of from file */ + let file = this.get('file'); + if (file) + response += ` from file "${file.name}"`; + this.setProperties({ + isProcessing: false, + successMessage: response, + }); + }, + setError(msg) { + this.setProperties({ + isProcessing: false, + errorMessage: msg, + }); + }, + setWarning(msg) { + this.setProperties({ + isProcessing: false, + successMessage: null, + errorMessage: null, + warningMessage: msg, + }); + }, + clearMsgs() { + this.setProperties({ + successMessage: null, + errorMessage: null, + warningMessage: null, + }); + }, + + /** Callback used by data upload, to report progress percent updates */ + updateProgress(percentComplete, direction) { + if (direction === 'up') { + if (percentComplete === 100) { + this.set('progressMsg', 'Please wait. Updating database.'); + } else { + this.set('progressMsg', + 'Please wait. File upload in progress (' + + percentComplete.toFixed(0) + '%)' ); + } + } else { + this.set('progressMsg', + 'Please wait. Receiving result (' + percentComplete.toFixed(0) + '%)' ); + } + }, + + +}; diff --git a/frontend/app/utils/panel/upload-table.js b/frontend/app/utils/panel/upload-table.js new file mode 100644 index 000000000..1f23d13f3 --- /dev/null +++ b/frontend/app/utils/panel/upload-table.js @@ -0,0 +1,161 @@ +import { debounce, later as run_later } from '@ember/runloop'; +import { observer, computed } from '@ember/object'; + + +const dLog = console.debug; + +/*----------------------------------------------------------------------------*/ + + +/** + * factored from components/panel/upload/data-csv.js + * May evolve this to a decorator or a sub-component. + * + * usage / dependencies : object using this defines : + * services : store, auth + * attributes : + selectedDataset: 'new', + newDatasetName: '', + nameWarning: null, + selectedParent: '', + dataType: 'linear', + namespace: '', + + */ + +export default { + + /** Returns a selected dataset name OR + * Attempts to create a new dataset with entered name */ + getDatasetId() { + var that = this; + let datasets = that.get('datasets'); + return new Promise(function(resolve, reject) { + var selectedMap = that.get('selectDataset'); + // If a selected dataset, can simply return it + // If no selectedMap, treat as default, 'new' + if (selectedMap && selectedMap !== 'new') { + resolve(selectedMap); + } else { + var newMap = that.get('newDatasetName'); + // Check if duplicate name + let matched = datasets.findBy('name', newMap); + if(matched){ + reject({ msg: `Dataset name '${newMap}' is already in use` }); + } else { + let newDetails = { + name: newMap, + type: that.get('dataType'), + namespace: that.get('namespace'), + blocks: [] + }; + let parentId = that.get('selectedParent'); + if (parentId && parentId.length > 0) { + newDetails.parentName = parentId; + } + let newDataset = that.get('store').createRecord('Dataset', newDetails); + newDataset.save().then(() => { + resolve(newDataset.id); + }); + } + } + }); + }, + + + /** Checks if entered dataset name is already taken in dataset list + * Debounced call through observer */ + isDupName: function() { + let selectedMap = this.get('selectedDataset'); + if (selectedMap === 'new') { + let newMap = this.get('newDatasetName'); + let datasets = this.get('datasets'); + let matched = datasets.findBy('name', newMap); + if(matched){ + this.set('nameWarning', `Dataset name '${newMap}' is already in use`); + return true; + } + } + this.set('nameWarning', null); + return false; + }, + + onNameChange // : observer('newDatasetName', function), + () { + debounce(this, this.isDupName, 500); + }, + + onSelectChange // : observer('selectedDataset', 'selectedParent', function ), + () { + this.clearMsgs(); + this.isDupName(); + this.checkBlocks(); + }, + + submitFile() { + const fnName = 'submitFile'; + var that = this; + that.clearMsgs(); + that.set('nameWarning', null); + var table = that.get('table'); + // 1. Check data and get cleaned copy + that.validateData() + .then((features) => { + if (features.length > 0) { + // 2. Get new or selected dataset name + that.getDatasetId().then((map_id) => { + var data = { + dataset_id: map_id, + parentName: that.get('selectedParent'), + features: features, + namespace: that.get('namespace'), + }; + that.setProcessing(); + that.scrollToTop(); + // 3. Submit upload to api + that.get('auth').tableUpload(data, that.updateProgress.bind(that)) + .then((res) => { + that.setSuccess(res.status); + that.scrollToTop(); + // On complete, trigger dataset list reload + // through controller-level function + let refreshed = that.get('refreshDatasets')(); + /* as in sequence-search.js : dnaSequenceInput() */ + const viewDataset = this.get('viewDatasetFlag'); + if (viewDataset) { + refreshed + .then(() => { + let + datasetName = map_id; + dLog(fnName, 'viewDataset', datasetName); + this.get('viewDataset')(datasetName, viewDataset); + }); + } + + }, (err, status) => { + console.log(err, status); + that.setError(err.responseJSON.error.message); + that.scrollToTop(); + if(that.get('selectedDataset') === 'new'){ + // If upload failed and here, a new record for new dataset name + // has been created by getDatasetId() and this should be undone + that.get('store') + .findRecord('Dataset', map_id, { reload: true }) + .then((rec) => rec.destroyRecord() + .then(() => rec.unloadRecord()) + ); + } + }); + }, (err) => { + that.setError(err.msg || err.message); + that.scrollToTop(); + }); + } + }, (err) => { + table.selectCell(err.r, err.c); + that.setError(err.msg); + that.scrollToTop(); + }); + }, + +};