1 /*
  2  * Copyright (c) 2012-2016 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/wiki/home/Documentation/page.view?name=extDevelopment">purchase an Ext license</a>.</p>
 17  *            <p>Additional Documentation:
 18  *              <ul>
 19  *                  <li><a href="https://www.labkey.org/wiki/home/Documentation/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 
171         if (config.parameters) {
172             Ext4.iterate(config.parameters, function(param, value) {
173                 baseParams['query.param.' + param] = value;
174             });
175         }
176 
177         if (config.containerFilter){
178             //baseParams['query.containerFilterName'] = config.containerFilter;
179             baseParams['containerFilter'] = config.containerFilter;
180         }
181 
182         if (config.ignoreFilter)
183             baseParams['query.ignoreFilter'] = 1;
184 
185         if (Ext4.isDefined(config.maxRows)){
186             baseParams['query.maxRows'] = config.maxRows;
187             if (config.maxRows < this.pageSize)
188                 this.pageSize = config.maxRows;
189 
190             if (config.maxRows === 0)
191                 this.pageSize = 0;
192         }
193 
194         if (config.viewName)
195             baseParams['query.viewName'] = config.viewName;
196 
197         if (config.columns)
198             baseParams['query.columns'] = Ext4.isArray(config.columns) ? config.columns.join(",") : config.columns;
199 
200         if (config.queryName)
201             baseParams['query.queryName'] = config.queryName;
202 
203         if (config.containerPath)
204             baseParams.containerPath = config.containerPath;
205 
206         if (config.pageSize && config.maxRows !== 0 && this.maxRows !== 0)
207             baseParams['limit'] = config.pageSize;
208 
209         //NOTE: sort() is a method in the store. it's awkward to support a param, but we do it since selectRows() uses it
210         if (this.initialConfig && this.initialConfig.sort)
211             baseParams['query.sort'] = this.initialConfig.sort;
212         delete config.sort; //important...otherwise the native sort() method is overridden
213 
214         if (config.sql){
215             baseParams.sql = config.sql;
216             this.updatable = false;
217         }
218         else {
219             this.updatable = true;
220         }
221 
222         LABKEY.Filter.appendFilterParams(baseParams, config.filterArray);
223 
224         return baseParams;
225     },
226 
227     //private
228     //NOTE: the purpose of this is to provide a way to modify the server-supplied metadata and supplement with a client-supplied object
229     onReaderLoad: function(meta){
230         //this.model.prototype.idProperty = this.proxy.reader.idProperty;
231 
232         if (meta.fields && meta.fields.length){
233             var fields = [];
234             Ext4.each(meta.fields, function(f){
235                 this.translateMetadata(f);
236 
237                 if (this.metadataDefaults){
238                     Ext4.Object.merge(f, this.metadataDefaults);
239                 }
240 
241                 if (this.metadata){
242                     //allow more complex metadata, per field
243                     if (this.metadata[f.name]){
244                         Ext4.Object.merge(f, this.metadata[f.name]);
245                     }
246                 }
247 
248                 fields.push(f.name);
249             }, this);
250 
251             if (meta.title)
252                 this.queryTitle = meta.title;
253 
254             //allow mechanism to add new fields via metadata
255             if (this.metadata){
256                 var field;
257                 for (var i in this.metadata){
258                     field = this.metadata[i];
259                     //TODO: we should investigate how convert() works and probably use this instead
260                     if (field.createIfDoesNotExist && Ext4.Array.indexOf(i)==-1){
261                         field.name = field.name || i;
262                         field.notFromServer = true;
263                         this.translateMetadata(field);
264                         if (this.metadataDefaults)
265                             Ext4.Object.merge(field, this.metadataDefaults);
266 
267                         meta.fields.push(Ext4.apply({}, field));
268                     }
269                 }
270             }
271             this.fireEvent('beforemetachange', this, meta);
272         }
273     },
274 
275     //private
276     translateMetadata: function(field){
277         LABKEY.ext4.Util.translateMetadata(field);
278     },
279 
280     //private
281     setModel: function(model){
282         // NOTE: if the query lacks a PK, which can happen with queries that dont represent physical tables,
283         // Ext adds a column to hold an Id.  In order to differentiate this from other fields we set defaults
284         this.model.prototype.fields.each(function(field){
285             if (field.name == '_internalId'){
286                 Ext4.apply(field, {
287                     hidden: true,
288                     calculatedField: true,
289                     shownInInsertView: false,
290                     shownInUpdateView: false,
291                     userEditable: false
292                 });
293             }
294         });
295         this.model = model;
296         this.implicitModel = false;
297     },
298 
299     //private
300     load: function(){
301         this.generateBaseParams();
302         this.proxy.on('exception', this.onProxyException, this, {single: true});
303         return this.callParent(arguments);
304     },
305 
306     //private
307     sync: function(){
308         this.generateBaseParams();
309 
310         if (!this.updatable){
311             alert('This store is not updatable');
312             return;
313         }
314 
315         if (!this.syncNeeded()){
316             this.fireEvent('synccomplete', this);
317             return;
318         }
319 
320         this.proxy.on('exception', this.onProxyException, this, {single: true});
321         return this.callParent(arguments);
322     },
323 
324     //private
325     update: function(){
326         this.generateBaseParams();
327 
328         if (!this.updatable){
329             alert('This store is not updatable');
330             return;
331         }
332         return this.callParent(arguments);
333     },
334 
335     //private
336     create: function(){
337         this.generateBaseParams();
338 
339         if (!this.updatable){
340             alert('This store is not updatable');
341             return;
342         }
343         return this.callParent(arguments);
344     },
345 
346     //private
347     destroy: function(){
348         this.generateBaseParams();
349 
350         if (!this.updatable){
351             alert('This store is not updatable');
352             return;
353         }
354         return this.callParent(arguments);
355     },
356 
357     /**
358      * 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.
359      * @name getCanonicalFieldName
360      * @function
361      * @param {String} fieldName The name of the field to test
362      * @returns {String} The normalized field name or null if not found
363      * @memberOf LABKEY.ext4.data.Store#
364      */
365     getCanonicalFieldName: function(fieldName){
366         var fields = this.getFields();
367         if (fields.get(fieldName)){
368             return fieldName;
369         }
370 
371         var name;
372 
373         var properties = ['name', 'fieldKeyPath'];
374         Ext4.each(properties, function(prop){
375             fields.each(function(field){
376                 if (field[prop].toLowerCase() == fieldName.toLowerCase()){
377                     name = field.name;
378                     return false;
379                 }
380             });
381 
382             if (name)
383                 return false;  //abort the loop
384         }, this);
385 
386         return name;
387     },
388 
389     //private
390     //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
391     onAdd: function(store, records, idx, opts){
392         var val, record;
393         this.getFields().each(function(meta){
394             if (meta.getInitialValue){
395                 for (var i=0;i<records.length;i++){
396                     record = records[i];
397                     val = meta.getInitialValue(record.get(meta.name), record, meta);
398                     record.set(meta.name, val);
399                 }
400             }
401         }, this);
402     },
403 
404     //private
405     onBeforeLoad: function(operation){
406         if (this.sql){
407             operation.sql = this.sql;
408         }
409         this.proxy.containerPath = this.containerPath;
410         this.proxy.extraParams = this.generateBaseParams();
411     },
412 
413     //private
414     //NOTE: maybe this should be a plugin to combos??
415     onLoad : function(store, records, success) {
416         if (!success)
417             return;
418         //the intent is to let the client set default values for created fields
419         var toUpdate = [];
420         this.getFields().each(function(f){
421             if (f.setValueOnLoad && (f.getInitialValue || f.defaultValue))
422                 toUpdate.push(f);
423         }, this);
424         if (toUpdate.length){
425             var allRecords = this.getRange();
426             for (var i=0;i<allRecords.length;i++){
427                 var rec = allRecords[i];
428                 for (var j=0;j<toUpdate.length;j++){
429                     var meta = toUpdate[j];
430                     if (meta.getInitialValue)
431                         rec.set(meta.name, meta.getInitialValue(rec.get(meta.name), rec, meta));
432                     else if (meta.defaultValue && !rec.get(meta.name))
433                         rec.set(meta.name, meta.defaultValue)
434                 }
435             }
436         }
437     },
438 
439     onProxyWrite: function(operation) {
440         var me = this,
441             success = operation.wasSuccessful(),
442             records = operation.getRecords();
443 
444         switch (operation.action) {
445             case 'saveRows':
446                 me.onSaveRows(operation, success);
447                 break;
448             default:
449                 console.log('something other than saveRows happened: ' + operation.action)
450         }
451 
452         if (success) {
453             me.fireEvent('write', me, operation);
454             me.fireEvent('datachanged', me);
455         }
456         //this is a callback that would have been passed to the 'create', 'update' or 'destroy' function and is optional
457         Ext4.callback(operation.callback, operation.scope || me, [records, operation, success]);
458 
459         //NOTE: this was created to give a single event to follow, regardless of success
460         this.fireEvent('synccomplete', this, operation, success);
461     },
462 
463     //private
464     processResponse : function(rows){
465         var idCol = this.proxy.reader.getIdProperty();
466         var row;
467         var record;
468         var index;
469         for (var idx = 0; idx < rows.length; ++idx)
470         {
471             row = rows[idx];
472 
473             //an example row in this situation would be the response from a delete command
474             if (!row || !row.values)
475                 return;
476 
477             //find the record using the id sent to the server
478             record = this.getById(row.oldKeys[idCol]);
479 
480             //records created client-side might not have a PK yet, so we try to use internalId to find it
481             //we defer to snapshot, since this will contain all records, even if the store is filtered
482             if (!record)
483                 record = (this.snapshot || this.data).get(row.oldKeys['_internalId']);
484 
485             if (!record)
486                 return;
487 
488             //apply values from the result row to the sent record
489             for (var col in record.data)
490             {
491                 //since the sent record might contain columns form a related table,
492                 //ensure that a value was actually returned for that column before trying to set it
493                 if (undefined !== row.values[col]){
494                     record.set(col, record.fields.get(col).convert(row.values[col], row.values));
495                 }
496 
497                 //clear any displayValue there might be in the extended info
498                 if (record.json && record.json[col])
499                     delete record.json[col].displayValue;
500             }
501 
502             //if the id changed, fixup the keys and map of the store's base collection
503             //HACK: this is using private data members of the base Store class. Unfortunately
504             //Ext Store does not have a public API for updating the key value of a record
505             //after it has been added to the store. This might break in future versions of Ext
506             if (record.internalId != row.values[idCol])
507             {
508                 //ISSUE 22289: we need to find the original index before changing the internalId, or the record will not get found
509                 index = this.data.indexOf(record);
510                 record.internalId = row.values[idCol];
511                 record.setId(row.values[idCol]);
512                 if (index > -1) {
513                     this.data.removeAt(index);
514                     this.data.insert(index, record);
515                 }
516             }
517 
518             //reset transitory flags and commit the record to let
519             //bound controls know that it's now clean
520             delete record.saveOperationInProgress;
521 
522             record.phantom = false;
523             record.commit();
524         }
525     },
526 
527     //private
528     getJson : function(response) {
529         return (response && undefined != response.getResponseHeader && undefined != response.getResponseHeader('Content-Type')
530                 && response.getResponseHeader('Content-Type').indexOf('application/json') >= 0)
531                 ? Ext4.JSON.decode(response.responseText)
532                 : null;
533     },
534 
535     //private
536     onSaveRows: function(operation, success){
537         var json = this.getJson(operation.response);
538         if (!json || !json.result)
539             return;
540 
541         for (var commandIdx = 0; commandIdx < json.result.length; ++commandIdx)
542         {
543             this.processResponse(json.result[commandIdx].rows);
544         }
545     },
546 
547     //private
548     onProxyException : function(proxy, response, operation, eOpts) {
549         var loadError = {message: response.statusText};
550         var json = this.getJson(response);
551 
552         if (json){
553             if (json && json.exception)
554                 loadError.message = json.exception;
555 
556             response.errors = json;
557 
558             this.processErrors(json);
559         }
560 
561         this.loadError = loadError;
562 
563         //TODO: is this the right behavior?
564         if (response && (response.status === 200 || response.status == 0)){
565             return;
566         }
567 
568         var message = (json && json.exception) ? json.exception : response.statusText;
569 
570         var messageBody;
571         switch(operation.action){
572             case 'read':
573                 messageBody = 'Could not load records';
574                 break;
575             case 'saveRows':
576                 messageBody = 'Could not save records';
577                 break;
578             default:
579                 messageBody = 'There was an error';
580         }
581 
582         if (message)
583             messageBody += ' due to the following error:' + "<br>" + message;
584         else
585             messageBody += ' due to an unexpected error';
586 
587         if (false !== this.fireEvent("exception", this, messageBody, response, operation)){
588 
589             if (!this.supressErrorAlert)
590                 Ext4.Msg.alert("Error", messageBody);
591 
592             console.log(response);
593         }
594     },
595 
596     processErrors: function(json){
597         Ext4.each(json.errors, function(error){
598             //the error object for 1 row.  1-based row numbering
599             if (Ext4.isDefined(error.rowNumber)){
600                 var record = this.getAt(error.rowNumber - 1);
601                 if (!record)
602                     return;
603 
604                 record.serverErrors = {};
605 
606                 Ext4.each(error.errors, function(e){
607                     if (!record.serverErrors[e.field])
608                         record.serverErrors[e.field] = [];
609 
610                     if (record.serverErrors[e.field].indexOf(e.message) == -1)
611                         record.serverErrors[e.field].push(e.message);
612                 }, this);
613             }
614         }, this);
615     },
616 
617     //private
618     // NOTE: these values are returned by the store in the 9.1 API format
619     // They provide the display value and information used in Missing value indicators
620     // They are used by the Ext grid when rendering or creating a tooltip.  They are deleted here prsumably b/c if the value
621     // is changed then we cannot count on them being accurate
622     onStoreUpdate : function(store, record, operation) {
623         for (var field  in record.getChanges()){
624             if (record.raw && record.raw[field]){
625                 delete record.raw[field].displayValue;
626                 delete record.raw[field].mvValue;
627             }
628         }
629     },
630 
631     syncNeeded: function(){
632         return this.getNewRecords().length > 0 ||
633             this.getUpdatedRecords().length > 0 ||
634             this.getRemovedRecords().length > 0
635     },
636 
637     /**
638      * @private
639      * Using the store's metadata, this method returns an Ext config object suitable for creating an Ext field.
640      * The resulting object is configured to be used in a form, as opposed to a grid.
641      * This is a convenience wrapper around LABKEY.ext4.Util.getFormEditorConfig
642      * <p>
643      * For information on using metadata, see LABKEY.ext4.Util
644      *
645      * @name getFormEditorConfig
646      * @function
647      * @param (string) fieldName The name of the field
648      * @param (object) config Optional. This object will be recursively applied to the default config object
649      * @returns {object} An Ext config object suitable to create a field component
650      */
651     getFormEditorConfig: function(fieldName, config){
652         var meta = this.findFieldMetadata(fieldName);
653         return LABKEY.ext4.Util.getFormEditorConfig(meta, config);
654     },
655 
656     /**
657      * @private
658      * Using the store's metadata, this method returns an Ext config object suitable for creating an Ext field.
659      * The resulting object is configured to be used in a grid, as opposed to a form.
660      * This is a convenience wrapper around LABKEY.ext4.Util.getGridEditorConfig
661      * <p>
662      * For information on using metadata, see LABKEY.ext4.Util
663      * @name getGridEditorConfig
664      * @function
665      * @param (string) fieldName The name of the field
666      * @param (object) config Optional. This object will be recursively applied to the default config object
667      * @returns {object} An Ext config object suitable to create a field component
668      */
669     getGridEditorConfig: function(fieldName, config){
670         var meta = this.findFieldMetadata(fieldName);
671         return LABKEY.ext4.Util.getGridEditorConfig(meta, config);
672     },
673 
674     /**
675      * Returns an Ext.util.MixedCollection containing the fields associated with this store
676      *
677      * @name getFields
678      * @function
679      * @returns {Ext.util.MixedCollection} The fields associated with this store
680      * @memberOf LABKEY.ext4.data.Store#
681      *
682      */
683     getFields: function(){
684         return this.proxy.reader.model.prototype.fields;
685     },
686 
687     /**
688      * Returns an array of the raw column objects returned from the server along with the query metadata
689      *
690      * @name getColumns
691      * @function
692      * @returns {Array} The columns associated with this store
693      * @memberOf LABKEY.ext4.data.Store#
694      *
695      */
696     getColumns: function(){
697         return this.proxy.reader.rawData.columnModel;
698     },
699 
700     /**
701      * Returns a field metadata object of the specified field
702      *
703      * @name findFieldMetadata
704      * @function
705      * @param {String} fieldName The name of the field
706      * @returns {Object} Metatdata for this field
707      * @memberOf LABKEY.ext4.data.Store#
708      *
709      */
710     findFieldMetadata : function(fieldName){
711         var fields = this.getFields();
712         if (!fields)
713             return null;
714 
715         return fields.get(fieldName);
716     },
717 
718     exportData : function(format) {
719         format = format || "excel";
720         if (this.sql)
721         {
722             LABKEY.Query.exportSql({
723                 schemaName: this.schemaName,
724                 sql: this.sql,
725                 format: format,
726                 containerPath: this.containerPath,
727                 containerFilter: this.containerFilter
728             });
729         }
730         else
731         {
732             var config = this.getExportConfig(format);
733             window.location = config.url;
734         }
735     },
736 
737     getExportConfig : function(format) {
738 
739         format = format || "excel";
740 
741         var params = {
742             schemaName: this.schemaName,
743             "query.queryName": this.queryName,
744             "query.containerFilterName": this.containerFilter
745         };
746 
747         if (this.columns) {
748             params["query.columns"] = Ext4.isArray(this.columns) ? this.columns.join(',') : this.columns;
749         }
750 
751         // These are filters that are custom created (aka not from a defined view).
752         LABKEY.Filter.appendFilterParams(params, this.filterArray);
753 
754         if (this.sortInfo) {
755             params["query.sort"] = ("DESC" === this.sortInfo.direction ? "-" : "") + this.sortInfo.field;
756         }
757 
758         var config = {
759             action: ("tsv" === format) ? "exportRowsTsv" : "exportRowsExcel",
760             params: params
761         };
762 
763         config.url = LABKEY.ActionURL.buildURL("query", config.action, this.containerPath, config.params);
764 
765         return config;
766     },
767 
768     //Ext3 compatability??
769     commitChanges: function(){
770         this.sync();
771     },
772 
773     //private
774     getKeyField: function(){
775         return this.model.prototype.idProperty;
776     },
777 
778     //private, experimental
779     getQueryConfig: function(){
780         return {
781             containerPath: this.containerPath,
782             schemaName: this.schemaName,
783             queryName: this.queryName,
784             viewName: this.viewName,
785             queryTitle: this.queryTitle,
786             sql: this.sql,
787             columns: this.columns,
788             filterArray: this.filterArray,
789             sort: this.initialConfig.sort,
790             maxRows: this.maxRows,
791             containerFilter: this.containerFilter
792         }
793     }
794 
795 });