1 /*
  2  * Copyright (c) 2012-2018 LabKey Corporation
  3  *
  4  * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0
  5  */
  6 
  7 /**
  8  * Constructs an extended ExtJS 4.2.1 Ext.data.Store configured for use in LabKey client-side applications.
  9  * @name LABKEY.ext4.data.Store
 10  * @class
 11  * LabKey extension to the <a href="http://docs.sencha.com/ext-js/4-0/#!/api/Ext.data.Store">Ext.data.Store</a> class,
 12  * which can retrieve data from a LabKey server, track changes, and update the server upon demand. This is most typically
 13  * used with data-bound user interface widgets, such as the Ext.grid.Panel.
 14  *
 15  * <p>If you use any of the LabKey APIs that extend Ext APIs, you must either make your code open source or
 16  * <a href="https://www.labkey.org/Documentation/wiki-page.view?name=extDevelopment">purchase an Ext license</a>.</p>
 17  *            <p>Additional Documentation:
 18  *              <ul>
 19  *                  <li><a href="https://www.labkey.org/Documentation/wiki-page.view?name=javascriptTutorial">Tutorial: Create Applications with the JavaScript API</a></li>
 20  *              </ul>
 21  *           </p>
 22  * @augments Ext.data.Store
 23  * @param config Configuration properties.
 24  * @param {String} config.schemaName The LabKey schema to query.
 25  * @param {String} config.queryName The query name within the schema to fetch.
 26  * @param {String} [config.sql] A LabKey SQL statement to execute to fetch the data. You may specify either a queryName or sql,
 27  * but not both. Note that when using sql, the store becomes read-only, as it has no way to know how to update/insert/delete the rows.
 28  * @param {String} [config.viewName] A saved custom view of the specified query to use if desired.
 29  * @param {String} [config.columns] A comma-delimited list of column names to fetch from the specified query. Note
 30  *  that the names may refer to columns in related tables using the form 'column/column/column' (e.g., 'RelatedPeptide/TrimmedPeptide').
 31  * @param {String} [config.sort] A base sort specification in the form of '[-]column,[-]column' ('-' is used for descending sort).
 32  * @param {Array} [config.filterArray] An array of LABKEY.Filter.FilterDefinition objects to use as the base filters.
 33  * @param {Boolean} [config.updatable] Defaults to true. Set to false to prohibit updates to this store.
 34  * @param {String} [config.containerPath] The container path from which to get the data. If not specified, the current container is used.
 35  * @param {Integer} [config.maxRows] The maximum number of rows returned by this query (defaults to showing all rows).
 36  * @param {Boolean} [config.ignoreFilter] True will ignore any filters applied as part of the view (defaults to false).
 37  * @param {String} [config.containerFilter] The container filter to use for this query (defaults to null).
 38  *      Supported values include:
 39  *       <ul>
 40  *           <li>"Current": Include the current folder only</li>
 41  *           <li>"CurrentAndSubfolders": Include the current folder and all subfolders</li>
 42  *           <li>"CurrentPlusProject": Include the current folder and the project that contains it</li>
 43  *           <li>"CurrentAndParents": Include the current folder and its parent folders</li>
 44  *           <li>"CurrentPlusProjectAndShared": Include the current folder plus its project plus any shared folders</li>
 45  *           <li>"AllFolders": Include all folders for which the user has read permission</li>
 46  *       </ul>
 47  * @param {Object} [config.metadata] A metadata object that will be applied to the default metadata returned by the server.  See example below for usage.
 48  * @param {Object} [config.metadataDefaults] A metadata object that will be applied to every field of the default metadata returned by the server.  Will be superceeded by the metadata object in case of conflicts. See example below for usage.
 49  * @param {boolean} [config.supressErrorAlert] If true, no dialog will appear if there is an exception.  Defaults to false.
 50  *
 51  * @example <script type="text/javascript">
 52     var _store;
 53 
 54     Ext4.onReady(function(){
 55 
 56         // create a Store bound to the 'Users' list in the 'core' schema
 57         _store = Ext4.create('LABKEY.ext4.data.Store', {
 58             schemaName: 'core',
 59             queryName: 'users',
 60             autoLoad: true
 61         });
 62     });
 63 
 64 </script>
 65 <div id='grid'/>
 66  */
 67 
 68 Ext4.define('LABKEY.ext4.data.Store', {
 69 
 70     extend: 'Ext.data.Store',
 71     alternateClassName: 'LABKEY.ext4.Store',
 72 
 73     alias: ['store.labkeystore', 'store.labkey-store'],
 74 
 75     //the page size defaults to 25, which can give odd behavior for combos or other applications.
 76     //applications that want to use paging should modify this.  100K matches the implicit client API pagesize
 77     pageSize: 100000,
 78 
 79     constructor: function(config) {
 80         config = config || {};
 81 
 82         config.updatable = Ext4.isDefined(config.updatable) ? config.updatable : true;
 83 
 84         var baseParams = this.generateBaseParams(config);
 85 
 86         Ext4.apply(this, config);
 87 
 88         //specify an empty fields array instead of a model.  the reader will creates a model later
 89         this.fields = [];
 90 
 91         this.proxy = this.getProxyConfig();
 92 
 93         //see note below
 94         var autoLoad = config.autoLoad;
 95         config.autoLoad = false;
 96         this.autoLoad = false;
 97         this.loading = autoLoad; //allows combos to properly set initial value w/ asyc store load
 98 
 99         // call the superclass's constructor
100         this.callParent([config]);
101 
102         //NOTE: if the config object contains a load lister it will be executed prior to this one...not sure if that's a problem or not
103         this.on('beforeload', this.onBeforeLoad, this);
104         this.on('load', this.onLoad, this);
105         this.on('update', this.onStoreUpdate, this);
106         this.on('add', this.onAdd, this);
107 
108         this.proxy.reader.on('datachange', this.onReaderLoad, this);
109 
110         //Add this here instead of allowing Ext.store to autoLoad to make sure above listeners are added before 1st load
111         if(autoLoad){
112             this.autoLoad = autoLoad;
113             Ext4.defer(this.load, 10, this, [
114                 typeof this.autoLoad == 'object' ? this.autoLoad : undefined
115             ]);
116         }
117 
118         /**
119          * @memberOf LABKEY.ext4.data.Store#
120          * @name beforemetachange
121          * @event
122          * @description Fired when the initial query metadata is returned from the server. Provides an opportunity to manipulate it.
123          * @param {Object} store A reference to the LABKEY store
124          * @param {Object} metadata The metadata object that will be supplied to the Ext.data.Model.
125          */
126 
127         /**
128          * @memberOf LABKEY.ext4.data.Store#
129          * @name exception
130          * @event
131          * @description Fired when there is an exception loading or saving data.
132          * @param {Object} store A reference to the LABKEY store
133          * @param {String} message The error message
134          * @param {Object} response The response object
135          * @param {Object} operation The Ext.data.Operation object
136          */
137 
138         /**
139          * @memberOf LABKEY.ext4.data.Store#
140          * @name synccomplete
141          * @event
142          * @description Fired when a sync operation is complete, which can include insert/update/delete events
143          * @param {Object} store A reference to the LABKEY store
144          */
145         this.addEvents('beforemetachange', 'exception', 'synccomplete');
146     },
147 
148     //private
149     getProxyConfig: function(){
150         return {
151             type: 'labkeyajax',
152             store: this,
153             timeout: this.timeout,
154             listeners: {
155                 scope: this,
156                 exception: this.onProxyException
157             },
158             extraParams: this.generateBaseParams()
159         }
160     },
161 
162     generateBaseParams: function(config){
163         if (config)
164             this.initialConfig = Ext4.apply({}, config);
165 
166         config = config || this;
167         var baseParams = {};
168         baseParams.schemaName = config.schemaName;
169         baseParams.apiVersion = 9.1;
170         // Issue 32269 - force key and other non-requested columns to be sent back
171         baseParams.minimalColumns = false;
172 
173         if (config.parameters) {
174             Ext4.iterate(config.parameters, function(param, value) {
175                 baseParams['query.param.' + param] = value;
176             });
177         }
178 
179         if (config.containerFilter){
180             //baseParams['query.containerFilterName'] = config.containerFilter;
181             baseParams['containerFilter'] = config.containerFilter;
182         }
183 
184         if (config.ignoreFilter)
185             baseParams['query.ignoreFilter'] = 1;
186 
187         if (Ext4.isDefined(config.maxRows)){
188             baseParams['query.maxRows'] = config.maxRows;
189             if (config.maxRows < this.pageSize)
190                 this.pageSize = config.maxRows;
191 
192             if (config.maxRows === 0)
193                 this.pageSize = 0;
194         }
195 
196         if (config.viewName)
197             baseParams['query.viewName'] = config.viewName;
198 
199         if (config.columns)
200             baseParams['query.columns'] = Ext4.isArray(config.columns) ? config.columns.join(",") : config.columns;
201 
202         if (config.queryName)
203             baseParams['query.queryName'] = config.queryName;
204 
205         if (config.containerPath)
206             baseParams.containerPath = config.containerPath;
207 
208         if (config.pageSize && config.maxRows !== 0 && this.maxRows !== 0)
209             baseParams['limit'] = config.pageSize;
210 
211         //NOTE: sort() is a method in the store. it's awkward to support a param, but we do it since selectRows() uses it
212         if (this.initialConfig && this.initialConfig.sort)
213             baseParams['query.sort'] = this.initialConfig.sort;
214         delete config.sort; //important...otherwise the native sort() method is overridden
215 
216         if (config.sql){
217             baseParams.sql = config.sql;
218             this.updatable = false;
219         }
220         else {
221             this.updatable = true;
222         }
223 
224         LABKEY.Filter.appendFilterParams(baseParams, config.filterArray);
225 
226         return baseParams;
227     },
228 
229     //private
230     //NOTE: the purpose of this is to provide a way to modify the server-supplied metadata and supplement with a client-supplied object
231     onReaderLoad: function(meta){
232         //this.model.prototype.idProperty = this.proxy.reader.idProperty;
233 
234         if (meta.fields && meta.fields.length){
235             var fields = [];
236             Ext4.each(meta.fields, function(f){
237                 this.translateMetadata(f);
238 
239                 if (this.metadataDefaults){
240                     Ext4.Object.merge(f, this.metadataDefaults);
241                 }
242 
243                 if (this.metadata){
244                     //allow more complex metadata, per field
245                     if (this.metadata[f.name]){
246                         Ext4.Object.merge(f, this.metadata[f.name]);
247                     }
248                 }
249 
250                 fields.push(f.name);
251             }, this);
252 
253             if (meta.title)
254                 this.queryTitle = meta.title;
255 
256             //allow mechanism to add new fields via metadata
257             if (this.metadata){
258                 var field;
259                 for (var i in this.metadata){
260                     field = this.metadata[i];
261                     //TODO: we should investigate how convert() works and probably use this instead
262                     if (field.createIfDoesNotExist && Ext4.Array.indexOf(i)==-1){
263                         field.name = field.name || i;
264                         field.notFromServer = true;
265                         this.translateMetadata(field);
266                         if (this.metadataDefaults)
267                             Ext4.Object.merge(field, this.metadataDefaults);
268 
269                         meta.fields.push(Ext4.apply({}, field));
270                     }
271                 }
272             }
273             this.fireEvent('beforemetachange', this, meta);
274         }
275     },
276 
277     //private
278     translateMetadata: function(field){
279         LABKEY.ext4.Util.translateMetadata(field);
280     },
281 
282     //private
283     setModel: function(model){
284         // NOTE: if the query lacks a PK, which can happen with queries that dont represent physical tables,
285         // Ext adds a column to hold an Id.  In order to differentiate this from other fields we set defaults
286         this.model.prototype.fields.each(function(field){
287             if (field.name == '_internalId'){
288                 Ext4.apply(field, {
289                     hidden: true,
290                     calculatedField: true,
291                     shownInInsertView: false,
292                     shownInUpdateView: false,
293                     userEditable: false
294                 });
295             }
296         });
297         this.model = model;
298         this.implicitModel = false;
299     },
300 
301     //private
302     load: function(){
303         this.generateBaseParams();
304         this.proxy.on('exception', this.onProxyException, this, {single: true});
305         return this.callParent(arguments);
306     },
307 
308     //private
309     sync: function(){
310         this.generateBaseParams();
311 
312         if (!this.updatable){
313             alert('This store is not updatable');
314             return;
315         }
316 
317         if (!this.syncNeeded()){
318             this.fireEvent('synccomplete', this);
319             return;
320         }
321 
322         this.proxy.on('exception', this.onProxyException, this, {single: true});
323         return this.callParent(arguments);
324     },
325 
326     //private
327     update: function(){
328         this.generateBaseParams();
329 
330         if (!this.updatable){
331             alert('This store is not updatable');
332             return;
333         }
334         return this.callParent(arguments);
335     },
336 
337     //private
338     create: function(){
339         this.generateBaseParams();
340 
341         if (!this.updatable){
342             alert('This store is not updatable');
343             return;
344         }
345         return this.callParent(arguments);
346     },
347 
348     //private
349     destroy: function(){
350         this.generateBaseParams();
351 
352         if (!this.updatable){
353             alert('This store is not updatable');
354             return;
355         }
356         return this.callParent(arguments);
357     },
358 
359     /**
360      * Returns the case-normalized fieldName.  The fact that field names are not normally case-sensitive, but javascript is case-sensitive can cause prolems.  This method is designed to allow you to convert a string into the casing used by the store.
361      * @name getCanonicalFieldName
362      * @function
363      * @param {String} fieldName The name of the field to test
364      * @returns {String} The normalized field name or null if not found
365      * @memberOf LABKEY.ext4.data.Store#
366      */
367     getCanonicalFieldName: function(fieldName){
368         var fields = this.getFields();
369         if (fields.get(fieldName)){
370             return fieldName;
371         }
372 
373         var name;
374 
375         var properties = ['name', 'fieldKeyPath'];
376         Ext4.each(properties, function(prop){
377             fields.each(function(field){
378                 if (field[prop].toLowerCase() == fieldName.toLowerCase()){
379                     name = field.name;
380                     return false;
381                 }
382             });
383 
384             if (name)
385                 return false;  //abort the loop
386         }, this);
387 
388         return name;
389     },
390 
391     //private
392     //NOTE: the intent of this is to allow fields to have an initial value defined through a function.  see getInitialValue in LABKEY.ext4.Util.getDefaultEditorConfig
393     onAdd: function(store, records, idx, opts){
394         var val, record;
395         this.getFields().each(function(meta){
396             if (meta.getInitialValue){
397                 for (var i=0;i<records.length;i++){
398                     record = records[i];
399                     val = meta.getInitialValue(record.get(meta.name), record, meta);
400                     record.set(meta.name, val);
401                 }
402             }
403         }, this);
404     },
405 
406     //private
407     onBeforeLoad: function(operation){
408         if (this.sql){
409             operation.sql = this.sql;
410         }
411         this.proxy.containerPath = this.containerPath;
412         this.proxy.extraParams = this.generateBaseParams();
413     },
414 
415     //private
416     //NOTE: maybe this should be a plugin to combos??
417     onLoad : function(store, records, success) {
418         if (!success)
419             return;
420         //the intent is to let the client set default values for created fields
421         var toUpdate = [];
422         this.getFields().each(function(f){
423             if (f.setValueOnLoad && (f.getInitialValue || f.defaultValue))
424                 toUpdate.push(f);
425         }, this);
426         if (toUpdate.length){
427             var allRecords = this.getRange();
428             for (var i=0;i<allRecords.length;i++){
429                 var rec = allRecords[i];
430                 for (var j=0;j<toUpdate.length;j++){
431                     var meta = toUpdate[j];
432                     if (meta.getInitialValue)
433                         rec.set(meta.name, meta.getInitialValue(rec.get(meta.name), rec, meta));
434                     else if (meta.defaultValue && !rec.get(meta.name))
435                         rec.set(meta.name, meta.defaultValue)
436                 }
437             }
438         }
439     },
440 
441     onProxyWrite: function(operation) {
442         var me = this,
443             success = operation.wasSuccessful(),
444             records = operation.getRecords();
445 
446         switch (operation.action) {
447             case 'saveRows':
448                 me.onSaveRows(operation, success);
449                 break;
450             default:
451                 console.log('something other than saveRows happened: ' + operation.action)
452         }
453 
454         if (success) {
455             me.fireEvent('write', me, operation);
456             me.fireEvent('datachanged', me);
457         }
458         //this is a callback that would have been passed to the 'create', 'update' or 'destroy' function and is optional
459         Ext4.callback(operation.callback, operation.scope || me, [records, operation, success]);
460 
461         //NOTE: this was created to give a single event to follow, regardless of success
462         this.fireEvent('synccomplete', this, operation, success);
463     },
464 
465     //private
466     processResponse : function(rows){
467         var idCol = this.proxy.reader.getIdProperty();
468         var row;
469         var record;
470         var index;
471         for (var idx = 0; idx < rows.length; ++idx)
472         {
473             row = rows[idx];
474 
475             //an example row in this situation would be the response from a delete command
476             if (!row || !row.values)
477                 return;
478 
479             //find the record using the id sent to the server
480             record = this.getById(row.oldKeys[idCol]);
481 
482             //records created client-side might not have a PK yet, so we try to use internalId to find it
483             //we defer to snapshot, since this will contain all records, even if the store is filtered
484             if (!record)
485                 record = (this.snapshot || this.data).get(row.oldKeys['_internalId']);
486 
487             if (!record)
488                 return;
489 
490             //apply values from the result row to the sent record
491             for (var col in record.data)
492             {
493                 //since the sent record might contain columns form a related table,
494                 //ensure that a value was actually returned for that column before trying to set it
495                 if (undefined !== row.values[col]){
496                     record.set(col, record.fields.get(col).convert(row.values[col], row.values));
497                 }
498 
499                 //clear any displayValue there might be in the extended info
500                 if (record.json && record.json[col])
501                     delete record.json[col].displayValue;
502             }
503 
504             //if the id changed, fixup the keys and map of the store's base collection
505             //HACK: this is using private data members of the base Store class. Unfortunately
506             //Ext Store does not have a public API for updating the key value of a record
507             //after it has been added to the store. This might break in future versions of Ext
508             if (record.internalId != row.values[idCol])
509             {
510                 //ISSUE 22289: we need to find the original index before changing the internalId, or the record will not get found
511                 index = this.data.indexOf(record);
512                 record.internalId = row.values[idCol];
513                 record.setId(row.values[idCol]);
514                 if (index > -1) {
515                     this.data.removeAt(index);
516                     this.data.insert(index, record);
517                 }
518             }
519 
520             //reset transitory flags and commit the record to let
521             //bound controls know that it's now clean
522             delete record.saveOperationInProgress;
523 
524             record.phantom = false;
525             record.commit();
526         }
527     },
528 
529     //private
530     getJson : function(response) {
531         return (response && undefined != response.getResponseHeader && undefined != response.getResponseHeader('Content-Type')
532                 && response.getResponseHeader('Content-Type').indexOf('application/json') >= 0)
533                 ? Ext4.JSON.decode(response.responseText)
534                 : null;
535     },
536 
537     //private
538     onSaveRows: function(operation, success){
539         var json = this.getJson(operation.response);
540         if (!json || !json.result)
541             return;
542 
543         for (var commandIdx = 0; commandIdx < json.result.length; ++commandIdx)
544         {
545             this.processResponse(json.result[commandIdx].rows);
546         }
547     },
548 
549     //private
550     onProxyException : function(proxy, response, operation, eOpts) {
551         var loadError = {message: response.statusText};
552         var json = this.getJson(response);
553 
554         if (json){
555             if (json && json.exception)
556                 loadError.message = json.exception;
557 
558             response.errors = json;
559 
560             this.processErrors(json);
561         }
562 
563         this.loadError = loadError;
564 
565         //TODO: is this the right behavior?
566         if (response && (response.status === 200 || response.status == 0)){
567             return;
568         }
569 
570         var message = (json && json.exception) ? json.exception : response.statusText;
571 
572         var messageBody;
573         switch(operation.action){
574             case 'read':
575                 messageBody = 'Could not load records';
576                 break;
577             case 'saveRows':
578                 messageBody = 'Could not save records';
579                 break;
580             default:
581                 messageBody = 'There was an error';
582         }
583 
584         if (message)
585             messageBody += ' due to the following error:' + "<br>" + message;
586         else
587             messageBody += ' due to an unexpected error';
588 
589         if (false !== this.fireEvent("exception", this, messageBody, response, operation)){
590 
591             if (!this.supressErrorAlert)
592                 Ext4.Msg.alert("Error", messageBody);
593 
594             console.log(response);
595         }
596     },
597 
598     processErrors: function(json){
599         Ext4.each(json.errors, function(error){
600             //the error object for 1 row.  1-based row numbering
601             if (Ext4.isDefined(error.rowNumber)){
602                 var record = this.getAt(error.rowNumber - 1);
603                 if (!record)
604                     return;
605 
606                 record.serverErrors = {};
607 
608                 Ext4.each(error.errors, function(e){
609                     if (!record.serverErrors[e.field])
610                         record.serverErrors[e.field] = [];
611 
612                     if (record.serverErrors[e.field].indexOf(e.message) == -1)
613                         record.serverErrors[e.field].push(e.message);
614                 }, this);
615             }
616         }, this);
617     },
618 
619     //private
620     // NOTE: these values are returned by the store in the 9.1 API format
621     // They provide the display value and information used in Missing value indicators
622     // They are used by the Ext grid when rendering or creating a tooltip.  They are deleted here prsumably b/c if the value
623     // is changed then we cannot count on them being accurate
624     onStoreUpdate : function(store, record, operation) {
625         for (var field  in record.getChanges()){
626             if (record.raw && record.raw[field]){
627                 delete record.raw[field].displayValue;
628                 delete record.raw[field].mvValue;
629             }
630         }
631     },
632 
633     syncNeeded: function(){
634         return this.getNewRecords().length > 0 ||
635             this.getUpdatedRecords().length > 0 ||
636             this.getRemovedRecords().length > 0
637     },
638 
639     /**
640      * @private
641      * Using the store's metadata, this method returns an Ext config object suitable for creating an Ext field.
642      * The resulting object is configured to be used in a form, as opposed to a grid.
643      * This is a convenience wrapper around LABKEY.ext4.Util.getFormEditorConfig
644      * <p>
645      * For information on using metadata, see LABKEY.ext4.Util
646      *
647      * @name getFormEditorConfig
648      * @function
649      * @param (string) fieldName The name of the field
650      * @param (object) config Optional. This object will be recursively applied to the default config object
651      * @returns {object} An Ext config object suitable to create a field component
652      */
653     getFormEditorConfig: function(fieldName, config){
654         var meta = this.findFieldMetadata(fieldName);
655         return LABKEY.ext4.Util.getFormEditorConfig(meta, config);
656     },
657 
658     /**
659      * @private
660      * Using the store's metadata, this method returns an Ext config object suitable for creating an Ext field.
661      * The resulting object is configured to be used in a grid, as opposed to a form.
662      * This is a convenience wrapper around LABKEY.ext4.Util.getGridEditorConfig
663      * <p>
664      * For information on using metadata, see LABKEY.ext4.Util
665      * @name getGridEditorConfig
666      * @function
667      * @param (string) fieldName The name of the field
668      * @param (object) config Optional. This object will be recursively applied to the default config object
669      * @returns {object} An Ext config object suitable to create a field component
670      */
671     getGridEditorConfig: function(fieldName, config){
672         var meta = this.findFieldMetadata(fieldName);
673         return LABKEY.ext4.Util.getGridEditorConfig(meta, config);
674     },
675 
676     /**
677      * Returns an Ext.util.MixedCollection containing the fields associated with this store
678      *
679      * @name getFields
680      * @function
681      * @returns {Ext.util.MixedCollection} The fields associated with this store
682      * @memberOf LABKEY.ext4.data.Store#
683      *
684      */
685     getFields: function(){
686         return this.proxy.reader.model.prototype.fields;
687     },
688 
689     /**
690      * Returns an array of the raw column objects returned from the server along with the query metadata
691      *
692      * @name getColumns
693      * @function
694      * @returns {Array} The columns associated with this store
695      * @memberOf LABKEY.ext4.data.Store#
696      *
697      */
698     getColumns: function(){
699         return this.proxy.reader.rawData.columnModel;
700     },
701 
702     /**
703      * Returns a field metadata object of the specified field
704      *
705      * @name findFieldMetadata
706      * @function
707      * @param {String} fieldName The name of the field
708      * @returns {Object} Metatdata for this field
709      * @memberOf LABKEY.ext4.data.Store#
710      *
711      */
712     findFieldMetadata : function(fieldName){
713         var fields = this.getFields();
714         if (!fields)
715             return null;
716 
717         return fields.get(fieldName);
718     },
719 
720     exportData : function(format) {
721         format = format || "excel";
722         if (this.sql)
723         {
724             LABKEY.Query.exportSql({
725                 schemaName: this.schemaName,
726                 sql: this.sql,
727                 format: format,
728                 containerPath: this.containerPath,
729                 containerFilter: this.containerFilter
730             });
731         }
732         else
733         {
734             var config = this.getExportConfig(format);
735             window.location = config.url;
736         }
737     },
738 
739     getExportConfig : function(format) {
740 
741         format = format || "excel";
742 
743         var params = {
744             schemaName: this.schemaName,
745             "query.queryName": this.queryName,
746             "query.containerFilterName": this.containerFilter
747         };
748 
749         if (this.columns) {
750             params["query.columns"] = Ext4.isArray(this.columns) ? this.columns.join(',') : this.columns;
751         }
752 
753         // These are filters that are custom created (aka not from a defined view).
754         LABKEY.Filter.appendFilterParams(params, this.filterArray);
755 
756         if (this.sortInfo) {
757             params["query.sort"] = ("DESC" === this.sortInfo.direction ? "-" : "") + this.sortInfo.field;
758         }
759 
760         var config = {
761             action: ("tsv" === format) ? "exportRowsTsv" : "exportRowsExcel",
762             params: params
763         };
764 
765         config.url = LABKEY.ActionURL.buildURL("query", config.action, this.containerPath, config.params);
766 
767         return config;
768     },
769 
770     //Ext3 compatability??
771     commitChanges: function(){
772         this.sync();
773     },
774 
775     //private
776     getKeyField: function(){
777         return this.model.prototype.idProperty;
778     },
779 
780     //private, experimental
781     getQueryConfig: function(){
782         return {
783             containerPath: this.containerPath,
784             schemaName: this.schemaName,
785             queryName: this.queryName,
786             viewName: this.viewName,
787             queryTitle: this.queryTitle,
788             sql: this.sql,
789             columns: this.columns,
790             filterArray: this.filterArray,
791             sort: this.initialConfig.sort,
792             maxRows: this.maxRows,
793             containerFilter: this.containerFilter
794         }
795     }
796 
797 });