source: trunk/grails-app/controllers/dbnp/visualization/VisualizeController.groovy @ 1991

Last change on this file since 1991 was 1991, checked in by robert@…, 12 years ago

Updated visualization controller and added log debug statements to module communication service

File size: 23.3 KB
Line 
1/**
2 * Visualize Controller
3 *
4 * This controller enables the user to visualize his data
5 *
6 * @author  robert@thehyve.nl
7 * @since       20110825
8 * @package     dbnp.visualization
9 *
10 * Revision information:
11 * $Rev$
12 * $Author$
13 * $Date$
14 */
15package dbnp.visualization
16
17import dbnp.studycapturing.*;
18import grails.converters.JSON
19import groovy.lang.Closure;
20
21import org.dbnp.gdt.*
22
23class VisualizeController {
24        def authenticationService
25        def moduleCommunicationService
26
27        /**
28         * Shows the visualization screen
29         */
30        def index = {
31                [ studies: Study.giveReadableStudies( authenticationService.getLoggedInUser() )]
32        }
33
34        def getStudies = {
35                def studies = Study.giveReadableStudies( authenticationService.getLoggedInUser() );
36                render studies as JSON
37        }
38
39        def getFields = {
40                def input_object
41                def studies
42
43                try{
44                        input_object = JSON.parse(params.get('data'))
45                        studies = input_object.get('studies').id
46                } catch(Exception e) {
47                        // TODO: properly handle this exception
48                        println e
49                }
50
51                def fields = [];
52                studies.each {
53                        /*
54                         Gather fields related to this study from GSCF.
55                         This requires:
56                         - a study.
57                         - a category variable, e.g. "events".
58                         - a type variable, either "domainfields" or "templatefields".
59                         */
60                        def study = Study.get(it)
61                        fields += getFields(study, "subjects", "domainfields")
62                        fields += getFields(study, "subjects", "templatefields")
63                        fields += getFields(study, "events", "domainfields")
64                        fields += getFields(study, "events", "templatefields")
65                        fields += getFields(study, "samplingEvents", "domainfields")
66                        fields += getFields(study, "samplingEvents", "templatefields")
67                        fields += getFields(study, "assays", "domainfields")
68                        fields += getFields(study, "assays", "templatefields")
69                        fields += getFields(study, "samples", "domainfields")
70                        fields += getFields(study, "samples", "domainfields")
71
72                        /*
73                         Gather fields related to this study from modules.
74                         This will use the getMeasurements RESTful service. That service returns measurement types, AKA features.
75                         It does not actually return measurements (the getMeasurementData call does).
76                         The getFields method (or rather, the getMeasurements service) requires one or more assays and will return all measurement
77                         types related to these assays.
78                         So, the required variables for such a call are:
79                         - a source variable, which can be obtained from AssayModule.list() (use the 'name' field)
80                         - a list of assays, which can be obtained with study.getAssays()
81                         */
82                        AssayModule.list().each { module ->
83                                def list = []
84                                list = getFields(module.name, study.getAssays())
85                                if(list!=null){
86                                        if(list.size()!=0){
87                                                fields += list
88                                        }
89                                }
90                        }
91
92                        // TODO: Maybe we should add study's own fields
93                }
94
95                render fields as JSON
96        }
97
98        def getVisualizationTypes = {
99                def types = [ [ "id": "barchart", "name": "Barchart"] ];
100                render types as JSON
101        }
102
103        def getFields(source, assays){
104                /*
105                 Gather fields related to this study from modules.
106                 This will use the getMeasurements RESTful service. That service returns measurement types, AKA features.
107                 It does not actually return measurements (the getMeasurementData call does).
108                 The getFields method (or rather, the getMeasurements service) requires one or more assays and will return all measurement
109                 types related to these assays.
110                 So, the required variables for such a call are:
111                 - a source variable, which can be obtained from AssayModule.list() (use the 'name' field)
112                 - a list of assays, which can be obtained with study.getAssays()
113                 */
114                def collection = []
115                def callUrl = ""
116
117                // Making a different call for each assay
118                // TODO: Change this to one call that requests fields for all assays, when you get that to work (in all cases)
119                assays.each { assay ->
120                        def urlVars = "assayToken="+assay.assayUUID
121                        AssayModule.list().each { module ->
122                                if(source==module.name){
123                                        try {
124                                                callUrl = module.url + "/rest/getMeasurements/query?"+urlVars
125                                                def json = moduleCommunicationService.callModuleRestMethodJSON( module.url, callUrl );
126                                                json.each{ jason ->
127                                                        collection.add(jason)
128                                                }
129                                        } catch(Exception e){
130                                                // Todo: properly handle this exception
131                                                println "No success with\n\t"+callUrl+"\n"+e
132                                                return null
133                                        }
134                                }
135                        }
136                }
137
138                def fields = []
139                // Formatting the data
140                collection.each { field ->
141                        fields << [ "id": createFieldId( name: field, source: source, type: "feature" ), "source": source, "category": "feature", "name": source+" feature "+field ]
142                }
143                return fields
144        }
145
146        def getFields(study, category, type){
147                /*
148                 Gather fields related to this study from GSCF.
149                 This requires:
150                 - a study.
151                 - a category variable, e.g. "events".
152                 - a type variable, either "domainfields" or "templatefields".
153                 */
154
155                // Collecting the data from it's source
156                def collection
157                def fields = []
158                def source = "GSCF"
159
160                // Gathering the data
161                if(category=="subjects"){
162                        if(type=="domainfields"){
163                                collection = Subject.giveDomainFields()
164                        }
165                        if(type=="templatefields"){
166                                collection = study.giveSubjectTemplates().fields
167                        }
168                }
169                if(category=="events"){
170                        if(type=="domainfields"){
171                                collection = Event.giveDomainFields()
172                        }
173                        if(type=="templatefields"){
174                                collection = study.giveEventTemplates().fields
175                        }
176                }
177                if(category=="samplingEvents"){
178                        if(type=="domainfields"){
179                                collection = SamplingEvent.giveDomainFields()
180                        }
181                        if(type=="templatefields"){
182                                collection = study.giveSamplingEventTemplates().fields
183                        }
184                }
185                if(category=="samples"){
186                        if(type=="domainfields"){
187                                collection = Sample.giveDomainFields()
188                        }
189                        if(type=="templatefields"){
190                                collection = study.giveEventTemplates().fields
191                        }
192                }
193                if(category=="assays"){
194                        if(type=="domainfields"){
195                                collection = Event.giveDomainFields()
196                        }
197                        if(type=="templatefields"){
198                                collection = study.giveEventTemplates().fields
199                        }
200                }
201
202                // Formatting the data
203                if(type=="domainfields"){
204                        collection.each { field ->
205                                fields << [ "id": createFieldId( name: field.name, source: source, type: category ), "source": source, "category": category, "name": category.capitalize()+" "+field.name ]
206                        }
207                }
208                if(type=="templatefields"){
209                        collection.each { field ->
210                                for(int i = 0; i < field.size(); i++){
211                                        fields << [ "id": createFieldId( id: field[i].id, name: field[i].name, source: source, type: category ), "source": source, "category": category, "name": category.capitalize()+" "+field[i].name ]
212                                }
213                        }
214                }
215
216                return fields
217        }
218
219        /**
220         * Retrieves data for the visualization itself.
221         */
222        def getData = {
223                // Extract parameters
224                // TODO: handle erroneous input data
225                def inputData = parseGetDataParams();
226               
227                // TODO: handle the case that we have multiple studies
228                def studyId = inputData.studyIds[ 0 ];
229                def study = Study.get( studyId as Integer );
230
231                // Find out what samples are involved
232                def samples = study.samples
233
234                // Retrieve the data for both axes for all samples
235                // TODO: handle the case of multiple fields on an axis
236                def fields = [ "x": inputData.columnIds[ 0 ], "y": inputData.rowIds[ 0 ] ];
237                def data = getAllFieldData( study, samples, fields );
238
239                // Group data based on the y-axis if categorical axis is selected
240                // TODO: handle categories and continuous data
241                def groupedData = groupFieldData( data );
242               
243                // Format data so it can be rendered as JSON
244                def returnData = formatData( groupedData, fields );
245               
246                render returnData as JSON
247        }
248
249        /**
250         * Parses the parameters given by the user into a proper list
251         * @return Map with 4 keys:
252         *              studyIds:       list with studyIds selected
253         *              rowIds:         list with fieldIds selected for the rows
254         *              columnIds:      list with fieldIds selected for the columns
255         *              visualizationType:      String with the type of visualization required
256         * @see getFields
257         * @see getVisualizationTypes
258         */
259        def parseGetDataParams() {
260                def studyIds, rowIds, columnIds, visualizationType;
261               
262                def inputData = params.get( 'data' );
263                try{
264                        def input_object = JSON.parse(inputData)
265                       
266                        studyIds = input_object.get('studies')*.id
267                        rowIds = input_object.get('rows')*.id
268                        columnIds = input_object.get('columns')*.id
269                        visualizationType = "barchart"
270                } catch(Exception e) {
271                        // TODO: properly handle this exception
272                        println e
273                }
274
275                return [ "studyIds" : studyIds, "rowIds": rowIds, "columnIds": columnIds, "visualizationType": visualizationType ];
276        }
277
278        /**
279         * Retrieve the field data for the selected fields
280         * @param study         Study for which the data should be retrieved
281         * @param samples       Samples for which the data should be retrieved
282         * @param fields        Map with key-value pairs determining the name and fieldId to retrieve data for. Example:
283         *                                              [ "x": "field-id-1", "y": "field-id-3" ]
284         * @return                      A map with the same keys as the input fields. The values in the map are lists of values of the
285         *                                      selected field for all samples. If a value could not be retrieved for a sample, null is returned. Example:
286         *                                              [ "x": [ 3, 6, null, 10 ], "y": [ "male", "male", "female", "female" ] ]
287         */
288        def getAllFieldData( study, samples, fields ) {
289                def fieldData = [:]
290                fields.each{ field ->
291                        fieldData[ field.key ] = getFieldData( study, samples, field.value );
292                }
293               
294                return fieldData;
295        }
296       
297        /**
298        * Retrieve the field data for the selected field
299        * @param study          Study for which the data should be retrieved
300        * @param samples        Samples for which the data should be retrieved
301        * @param fieldId        ID of the field to return data for
302        * @return                       A list of values of the selected field for all samples. If a value
303        *                                       could not be retrieved for a sample, null is returned. Examples:
304        *                                               [ 3, 6, null, 10 ] or [ "male", "male", "female", "female" ]
305        */
306        def getFieldData( study, samples, fieldId ) {
307                // Parse the fieldId as given by the user
308                def parsedField = parseFieldId( fieldId );
309               
310                def data = []
311               
312                if( parsedField.source == "GSCF" ) {
313                        // Retrieve data from GSCF itself
314                        def closure = valueCallback( parsedField.type )
315                       
316                        if( closure ) {
317                                samples.each { sample ->
318                                        // Retrieve the value for the selected field for this sample
319                                        def value = closure( sample, parsedField.name );
320                                       
321                                        if( value ) {
322                                                data << value;
323                                        } else {
324                                                // Return null if the value is not found
325                                                data << null
326                                        }
327                                }
328                        } else {
329                                // TODO: Handle error properly
330                                // Closure could not be retrieved, probably because the type is incorrect
331                                data = samples.collect { return null }
332                        }
333                } else {
334                        // Data must be retrieved from a module
335                        data = getModuleData( study, samples, parsedField.source, parsedField.name );
336                }
337               
338                return data
339        }
340       
341        /**
342         * Retrieve data for a given field from a data module
343         * @param study                 Study to retrieve data for
344         * @param samples               Samples to retrieve data for
345         * @param source_module Name of the module to retrieve data from
346         * @param fieldName             Name of the measurement type to retrieve (i.e. measurementToken)
347         * @return                              A list of values of the selected field for all samples. If a value
348         *                                              could not be retrieved for a sample, null is returned. Examples:
349         *                                                      [ 3, 6, null, 10 ] or [ "male", "male", "female", "female" ]
350         */
351        def getModuleData( study, samples, source_module, fieldName ) {
352                def data = []
353               
354                // TODO: Handle values that should be retrieved from multiple assays
355                // TODO: Use Assay ID or AssayModule ID in field-ids, instead of names
356                def assay = study.assays.find { it.module.name == source_module };
357               
358                if( assay ) {
359                        // Request for a particular assay and a particular feature
360                        def urlVars = "assayToken=" + assay.assayUUID + "&measurementToken="+fieldName
361                        urlVars += "&" + samples.collect { "sampleToken=" + it.sampleUUID }.join( "&" );
362                       
363                        def callUrl
364                        try {
365                                callUrl = assay.module.url + "/rest/getMeasurementData"
366                                def json = moduleCommunicationService.callModuleMethod( assay.module.url, callUrl, urlVars, "POST" );
367                               
368                                if( json ) {
369                                        // First element contains sampletokens
370                                        // Second element contains the featurename
371                                        // Third element contains the measurement value
372                                        def sampleTokens = json[ 0 ]
373                                        def measurements = json[ 2 ]
374                                       
375                                        // Loop through the samples
376                                        samples.each { sample ->
377                                                // Search for this sampletoken
378                                                def sampleToken = sample.sampleUUID;
379                                                def index = sampleTokens.findIndexOf { it == sampleToken }
380                                               
381                                                if( index > -1 ) {
382                                                        data << measurements[ index ];
383                                                } else {[ "x": inputData.columnIds[ 0 ], "y": inputData.rowIds[ 0 ] ]
384                                                        data << null
385                                                }
386                                        }
387                                } else {
388                                        // TODO: handle error
389                                        // Returns an empty list with as many elements as there are samples
390                                        data = samples.collect { return null }
391                                }
392                               
393                        } catch(Exception e){
394                                // TODO: handle this exception properly
395                                println "No success with\n\t"+callUrl+"\n"+e
396                                e.printStackTrace();
397                               
398                                // Returns an empty list with as many elements as there are samples
399                                data = samples.collect { return null }
400                        }
401                } else {
402                        // TODO: Handle error correctly
403                        // Returns an empty list with as many elements as there are samples
404                        data = samples.collect { return null }
405                }
406               
407                return data
408
409        }
410
411        /**
412         * Group the field data on the values of the specified axis. For example, for a bar chart, the values
413         * on the x-axis should be grouped. Currently, the values for each group are averaged, and the standard
414         * error of the mean is returned in the 'error' property
415         * @param data          Data for both group- and value axis. The output of getAllFieldData fits this input
416         * @param groupAxis     Name of the axis to group on. Defaults to "x"
417         * @param valueAxis     Name of the axis where the values are. Defaults to "y"
418         * @param errorName     Key in the output map where 'error' values (SEM) are stored. Defaults to "error"
419         * @param unknownName   Name of the group for all null groups. Defaults to "unknown"
420         * @return                      A map with the keys 'groupAxis', 'valueAxis' and 'errorName'. The values in the map are lists of values of the
421         *                                      selected field for all groups. For example, if the input is
422         *                                              [ "x": [ "male", "male", "female", "female", null, "female" ], "y": [ 3, 6, null, 10, 4, 5 ] ]
423         *                                      the output will be:
424         *                                              [ "x": [ "male", "female", "unknown" ], "y": [ 4.5, 7.5, 4 ], "error": [ 1.5, 2.5, 0 ] ]
425         *
426         *                                      As you can see: null values in the valueAxis are ignored. Null values in the
427         *                                      group axis are combined into a 'unknown' category.
428         */
429        def groupFieldData( data, groupAxis = "x", valueAxis = "y", errorName = "error", unknownName = "unknown" ) {
430                // Create a unique list of values in the groupAxis. First flatten the list, since it might be that a
431                // sample belongs to multiple groups. In that case, the group names should not be the lists, but the list
432                // elements. A few lines below, this case is handled again by checking whether a specific sample belongs
433                // to this group.
434                // After flattening, the list is uniqued. The closure makes sure that values with different classes are
435                // always treated as different items (e.g. "" should not equal 0, but it does if using the default comparator)
436                def groups = data[ groupAxis ]
437                                                .flatten()
438                                                .unique { it == null ? "null" : it.class.name + it.toString() }
439               
440                // Make sure the null category is last
441                groups = groups.findAll { it != null } + groups.findAll { it == null }
442               
443                // Gather names for the groups. Most of the times, the group names are just the names, only with
444                // a null value, the unknownName must be used
445                def groupNames = groups.collect { it != null ? it : unknownName }
446               
447                // Generate the output object
448                def outputData = [:]
449                outputData[ valueAxis ] = [];
450                outputData[ errorName ] = [];
451                outputData[ groupAxis ] = groupNames;
452               
453                // Loop through all groups, and gather the values for this group
454                groups.each { group ->
455                        // Find the indices of the samples that belong to this group. if a sample belongs to multiple groups (i.e. if
456                        // the samples groupAxis contains multiple values, is a collection), the value should be used in all groups.
457                        def indices= data[ groupAxis ].findIndexValues { it instanceof Collection ? it.contains( group ) : it == group };
458                        def values = data[ valueAxis ][ indices ]
459                       
460                        def dataForGroup = computeMeanAndError( values );
461                       
462                        outputData[ valueAxis ] << dataForGroup.value
463                        outputData[ errorName ] << dataForGroup.error 
464                }
465
466                return outputData
467        }
468       
469        /**
470         * Formats the grouped data in such a way that the clientside visualization method
471         * can handle the data correctly.
472         * @param groupedData   Data that has been grouped using the groupFields method
473         * @param fields                Map with key-value pairs determining the name and fieldId to retrieve data for. Example:
474         *                                                      [ "x": "field-id-1", "y": "field-id-3" ]
475         * @param groupAxis             Name of the axis to with group data. Defaults to "x"
476         * @param valueAxis             Name of the axis where the values are stored. Defaults to "y"
477         * @param errorName             Key in the output map where 'error' values (SEM) are stored. Defaults to "error"         *
478         * @return                              A map like the following:
479         *
480                        {
481                                "type": "barchart",
482                                "x": [ "Q1", "Q2", "Q3", "Q4" ],
483                                "xaxis": { "title": "quarter 2011", "unit": "" },
484                                "yaxis": { "title": "temperature", "unit": "degrees C" },
485                                "series": [
486                                        {
487                                                "name": "series name",
488                                                "y": [ 5.1, 3.1, 20.6, 15.4 ],
489                                                "error": [ 0.5, 0.2, 0.4, 0.5 ]
490                                        },
491                                ]
492                        }
493         *
494         */
495        def formatData( groupedData, fields, groupAxis = "x", valueAxis = "y", errorName = "error" ) {
496                // TODO: Handle name and unit of fields correctly
497               
498                def return_data = [:]
499                return_data[ "type" ] = "barchart"
500                return_data[ "x" ] = groupedData[ groupAxis ].collect { it.toString() }
501                return_data.put("yaxis", ["title" : parseFieldId( fields[ valueAxis ] ).name, "unit" : "" ])
502                return_data.put("xaxis", ["title" : parseFieldId( fields[ groupAxis ] ).name, "unit": "" ])
503                return_data.put("series", [[
504                        "name": "Y",
505                        "y": groupedData[ valueAxis ],
506                        "error": groupedData[ errorName ]
507                ]])
508               
509                return return_data;
510        }
511
512        /**
513         * Returns a closure for the given entitytype that determines the value for a criterion
514         * on the given object. The closure receives two parameters: the sample and a field.
515         *
516         * For example:
517         *              How can one retrieve the value for subject.name, given a sample? This can be done by
518         *              returning the field values sample.parentSubject:
519         *                      { sample, field -> return getFieldValue( sample.parentSubject, field ) }
520         * @return      Closure that retrieves the value for a field and the given field
521         */
522        protected Closure valueCallback( String entity ) {
523                switch( entity ) {
524                        case "Study":
525                        case "studies":
526                                return { sample, field -> return getFieldValue( sample.parent, field ) }
527                        case "Subject":
528                        case "subjects":
529                                return { sample, field -> return getFieldValue( sample.parentSubject, field ); }
530                        case "Sample":
531                        case "samples":
532                                return { sample, field -> return getFieldValue( sample, field ) }
533                        case "Event":
534                        case "events":
535                                return { sample, field ->
536                                        if( !sample || !sample.parentEventGroup || !sample.parentEventGroup.events || sample.parentEventGroup.events.size() == 0 )
537                                                return null
538
539                                        return sample.parentEventGroup.events?.collect { getFieldValue( it, field ) };
540                                }
541                        case "SamplingEvent":
542                        case "samplingEvents":
543                                return { sample, field -> return getFieldValue( sample.parentEvent, field ); }
544                        case "Assay":
545                        case "assays":
546                                return { sample, field ->
547                                        def sampleAssays = Assay.findByParent( sample.parent ).findAll { it.samples?.contains( sample ) };
548                                        if( sampleAssays && sampleAssays.size() > 0 )
549                                                return sampleAssays.collect { getFieldValue( it, field ) }
550                                        else
551                                                return null
552                                }
553                }
554        }
555       
556        /**
557         * Computes the mean value and Standard Error of the mean (SEM) for the given values
558         * @param values        List of values to compute the mean and SEM for. Strings and null
559         *                                      values are ignored
560         * @return                      Map with two keys: 'value' and 'error'
561         */
562        protected Map computeMeanAndError( values ) {
563                // TODO: Handle the case that one of the values is a list. In that case,
564                // all values should be taken into account.     
565                def mean = computeMean( values );
566                def error = computeSEM( values, mean );
567               
568                return [ 
569                        "value": mean,
570                        "error": error
571                ]
572        }
573       
574        /**
575         * Computes the mean of the given values. Values that can not be parsed to a number
576         * are ignored. If no values are given, the mean of 0 is returned.
577         * @param values        List of values to compute the mean for
578         * @return                      Arithmetic mean of the values
579         */
580        protected def computeMean( List values ) {
581                def sumOfValues = 0;
582                def sizeOfValues = 0;
583                values.each { value ->
584                        def num = getNumericValue( value );
585                        if( num != null ) {
586                                sumOfValues += num;
587                                sizeOfValues++
588                        } 
589                }
590
591                if( sizeOfValues > 0 )
592                        return sumOfValues / sizeOfValues;
593                else
594                        return 0;
595        }
596
597        /**
598        * Computes the standard error of mean of the given values. 
599        * Values that can not be parsed to a number are ignored. 
600        * If no values are given, the standard deviation of 0 is returned.
601        * @param values         List of values to compute the standard deviation for
602        * @param mean           Mean of the list (if already computed). If not given, the mean
603        *                                       will be computed using the computeMean method
604        * @return                       Standard error of the mean of the values or 0 if no values can be used.
605        */
606   protected def computeSEM( List values, def mean = null ) {
607           if( mean == null )
608                        mean = computeMean( values )
609           
610           def sumOfDifferences = 0;
611           def sizeOfValues = 0;
612           values.each { value ->
613                   def num = getNumericValue( value );
614                   if( num != null ) {
615                           sumOfDifferences += Math.pow( num - mean, 2 );
616                           sizeOfValues++
617                   }
618           }
619
620           if( sizeOfValues > 0 ) {
621                   def std = Math.sqrt( sumOfDifferences / sizeOfValues );
622                   return std / Math.sqrt( sizeOfValues );
623           } else {
624                   return 0;
625           }
626   }
627               
628        /**
629         * Return the numeric value of the given object, or null if no numeric value could be returned
630         * @param       value   Object to return the value for
631         * @return                      Number that represents the given value
632         */
633        protected Number getNumericValue( value ) {
634                // TODO: handle special types of values
635                if( value instanceof Number ) {
636                        return value;
637                } else if( value instanceof RelTime ) {
638                        return value.value;
639                }
640               
641                return null
642        }
643
644        /** 
645         * Returns a field for a given templateentity
646         * @param object        TemplateEntity (or subclass) to retrieve data for
647         * @param fieldName     Name of the field to return data for.
648         * @return                      Value of the field or null if the value could not be retrieved
649         */
650        protected def getFieldValue( TemplateEntity object, String fieldName ) {
651                if( !object || !fieldName )
652                        return null;
653               
654                try {
655                        return object.getFieldValue( fieldName );
656                } catch( Exception e ) {
657                        return null;
658                }
659        }
660
661        /**
662         * Parses a fieldId that has been created earlier by createFieldId
663         * @param fieldId       FieldId to parse
664         * @return                      Map with attributes of the selected field. Keys are 'name', 'id', 'source' and 'type'
665         * @see createFieldId
666         */
667        protected Map parseFieldId( String fieldId ) {
668                def attrs = [:]
669               
670                def parts = fieldId.split(",")
671               
672                attrs = [
673                        "id": parts[ 0 ],
674                        "name": parts[ 1 ],
675                        "source": parts[ 2 ],
676                        "type": parts[ 3 ]
677                ]
678        }
679       
680        /**
681         * Create a fieldId based on the given attributes
682         * @param attrs         Map of attributes for this field. Keys may be 'name', 'id', 'source' and 'type'
683         * @return                      Unique field ID for these parameters
684         * @see parseFieldId
685         */
686        protected String createFieldId( Map attrs ) {
687                // TODO: What is one of the attributes contains a comma?
688                def name = attrs.name;
689                def id = attrs.id ?: name;
690                def source = attrs.source;
691                def type = attrs.type ?: ""
692               
693                return id + "," + name + "," + source + "," + type;
694        }
695
696}
Note: See TracBrowser for help on using the repository browser.