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