Menu

Having multiple numeric autofilter expression

adealex
2010-12-15
2013-05-08
  • adealex

    adealex - 2010-12-15

    Hi Edwin,

    I was looking at the auto-filter row and have been given the requirement for users to be able to specify more than one expression for numeric fields. For example, the user might want to see all rows that lie within the range 50 - 100. Right now, it looks like nattables can only support one expression per column. I would like to suggest a code change that allows users to specify multiple numerical expressions (which are AND-ed) alongside string expressions which are OR-ed. Thus, a filter value of "<100 >50 2 7" means, 'show me all rows that satisfy value greater than 50 AND less than 100 AND contain either the digits 2 or 7'. This kind of compounding of filters will allow us to build up very powerful expressions in the filter row:

    FilterRowUtils.java:

        public static List<ParseResult> parse(String string) {
            Scanner scanner = new Scanner(string.trim());
            Pattern p = Pattern.compile("[>|<]?=?");
    
            List<ParseResult> parseResults = new ArrayList<ParseResult>();
    
            ParseResult parseResult = null;
            while (scanner.hasNext()) {
                parseResult = new ParseResult();
    
                Scanner childScanner = new Scanner(scanner.next().trim());
                String opToken = childScanner.findWithinHorizon(p, 2);
                if (isNotEmpty(opToken)) {
                    parseResult.setMatchType(MatchType.parse(opToken));
                }
    
                if (childScanner.hasNext()) {
                    String nextVal = childScanner.next();
                    parseResult.setValueToMatch(nextVal);
                }
    
                parseResults.add(parseResult);
            }
            scanner.close();
            return parseResults;
        }
    

    FilterRowDataProvider.java:

        /**
         * Create GlazedLsists matcher editors and apply them to facilitate filtering.
         */
        private void applyFilter() {
            EventList<MatcherEditor<T>> numericMatcherEditors = new BasicEventList<MatcherEditor<T>>();
            EventList<MatcherEditor<T>> stringMatcherEditors = new BasicEventList<MatcherEditor<T>>();
            EventList<MatcherEditor<T>> parentMatcherEditors = new BasicEventList<MatcherEditor<T>>();
            boolean hasStringMatchers = false;
            boolean hasNumericMatchers = false;
    
            try {
                for (Entry<Integer, String> mapEntry : filterTextByIndex.entrySet()) {
                    Integer columnIndex = mapEntry.getKey();
                    String filterText = mapEntry.getValue();
                    List<ParseResult> parseResults = FilterRowUtils.parse(filterText);
    
                    for (ParseResult parseResult : parseResults)
                    {
                        if (parseResult.getMatchOperation() == MatchType.NONE) {
                            stringMatcherEditors.add(getTextMatcherEditor(columnIndex, parseResult.getValuetoMatch()));
                            hasStringMatchers = true;
                        } else {
                            numericMatcherEditors.add(getThresholdMatcherEditor(columnIndex, parseResult));
                            hasNumericMatchers = true;
                        }
                    }
                }
    
                CompositeMatcherEditor<T> numericCompositeMatcherEditor = new CompositeMatcherEditor<T>(numericMatcherEditors);
                numericCompositeMatcherEditor.setMode(CompositeMatcherEditor.AND);
                CompositeMatcherEditor<T> stringCompositeMatcherEditor = new CompositeMatcherEditor<T>(stringMatcherEditors);
                stringCompositeMatcherEditor.setMode(CompositeMatcherEditor.OR);
                if (hasNumericMatchers)
                    parentMatcherEditors.add(numericCompositeMatcherEditor);
    
                if (hasStringMatchers)
                    parentMatcherEditors.add(stringCompositeMatcherEditor);
    
                CompositeMatcherEditor<T> allParentMatcherEditor = new CompositeMatcherEditor<T>(parentMatcherEditors);
                allParentMatcherEditor.setMode(CompositeMatcherEditor.AND);
    
                filterList.setMatcherEditor(allParentMatcherEditor);
            }catch (Exception e) {
                System.err.println(e.getMessage());
            }
        }
    

    Regards

    Adealex

     
  • adealex

    adealex - 2010-12-16

    Hi,

    I just noticed a bug in the code I included in the message above. Please replace the applyFilter method with this one rather than the one I originally posted further up this message chain:

        private void applyFilter() {
            EventList<MatcherEditor<T>> matcherEditors = new BasicEventList<MatcherEditor<T>>();
    
            try {
                for (Entry<Integer, String> mapEntry : filterTextByIndex.entrySet()) {
                    Integer columnIndex = mapEntry.getKey();
                    String filterText = mapEntry.getValue();
                    List<ParseResult> parseResults = FilterRowUtils.parse(filterText);
                    EventList<MatcherEditor<T>> stringMatcherEditors = new BasicEventList<MatcherEditor<T>>();
                    for (ParseResult parseResult : parseResults)
                    {
                        if (parseResult.getMatchOperation() == MatchType.NONE) {
                            stringMatcherEditors.add(getTextMatcherEditor(columnIndex, parseResult.getValuetoMatch()));
                        } else {
                            matcherEditors.add(getThresholdMatcherEditor(columnIndex, parseResult));
                        }
                    }
    
                    if (stringMatcherEditors.size()>0){
                        CompositeMatcherEditor<T> stringCompositeMatcherEditor = new CompositeMatcherEditor<T>(stringMatcherEditors);
                        stringCompositeMatcherEditor.setMode(CompositeMatcherEditor.OR);
                        matcherEditors.add(stringCompositeMatcherEditor);
                    }
                }
    
                CompositeMatcherEditor<T> matcherEditor = new CompositeMatcherEditor<T>(matcherEditors);
                matcherEditor.setMode(CompositeMatcherEditor.AND);
    
                filterList.setMatcherEditor(matcherEditor);
            }catch (Exception e) {
                System.err.println(e.getMessage());
            }
        }
    

    Thanks,
    Adealex.

     
  • Edwin Park

    Edwin Park - 2010-12-20

    Hi Adealex -

    This is great. I'm just thinking that it may be better to externalize the interpretation of the filter string as a strategy class or something so that folks can customize this behavior, rather than just changing the default implementation. I'm poking around at this now, will post back on status in a bit.

    Thanks,
    Edwin

     
  • adealex

    adealex - 2010-12-23

    Hi Edwin,

    Thanks for looking at this. Can I ask how the strategy class is going? I was thinking that the above code doesn`t counter the proposals you suggest, rather, my changes make the current workings more usable. Therefore, if the new strategy class is some way off yet, it may make sense to continue with the current approach and check in the above code.

    What are your thoughts?

    Regards,
    Adealex.

     
  • Edwin Park

    Edwin Park - 2010-12-24

    Hi Adealex -

    I just checked in the changes. Sorry for the delay - it's the holiday season and I'm technically on vacation so things have been going a little slow. :-)

    I wound up refactoring the filter row stuff quite a bit. Most of it is reorganization. The functionality didn't really change at all apart from your patch allowing multiple filter expressions per column. All of the GlazedLists-specific stuff is now isolated in a FilterStrategy class. I was then able to promote the rest of the filter row functionality into nattable.core. The packages have been reorganized to make more sense. There are subpackages to group filterrow events, config, actions, etc. The nattable.extension.glazedlists plugin now exports only packages under net.sourceforge.nattable.extension.glazedlists (it used to export net.sourceforge.nattable.filterrow).

    NOTE: you may need to change your code a bit if you are using a NatTable implementation with filter row functionality due to the above reorganization. It's not a big change, it just means that instead of your setup code looking like:

    new FilterRowDataProvider<RowDataFixture>(
    filterList,
    columnPropertyAccessor,
    configRegistry,
    columnHeaderLayer
    );

    it will now need to look like:

    new FilterRowDataProvider<T>(
    new DefaultGlazedListsFilterStrategy<T>(
    filterList,
    columnPropertyAccessor,
    configRegistry
    ),
    columnHeaderLayer
    );

    Please kick the tires and let me know what you think.

    Thanks,
    Edwin

     
  • adealex

    adealex - 2010-12-29

    Hi Edwin,

    Hope you are having a good holiday season. I have just come back from vacation myself and started to look at these changes. There has been quite a bit of movement in the code and I am a little overwhelmed! Thanks for doing this work, I can confirm the FilterRowGridExample application seems to behave as intended. I can see that the most of the filtering code I submitted above is now in the class DefaultGlazedListsFilterStrategy which is cool.

    I would like to suggest some more changes to this class as I would prefer not to obliterate the filters on the filterlist each time there is a change to the filter value. Here is the line of code that concerns me:

    filterList.setMatcherEditor(getMatcherEditor(filterTextByIndex));

    …which gets called each time the applyFilter method is called. Let me first explain the motivation for this. I am interested in a having my filter list that is filtered based on the autofilters nattable provides. In addition to the autofilters, it may have some further filtering elsewhere in my application codebase. After all, my application owns the filter list so it should be allowed to set up some filtering of its own that should happily co-exist with the autofiltering.

    As an interim step towards our end goal however, lets just focus on creating an autofiltering system that can run without resetting the entire filtering system on the filterlist:

    DefaultGlazedListsFilterStrategy:

    New field declaration:

        private final FilterList<T> filterList;
        private final IColumnAccessor<T> columnAccessor;
        private final IConfigRegistry configRegistry;[b]
        private final CompositeMatcherEditor<T> matcherEditor;[/b]
    

    The constructor should now look like this:

        public DefaultGlazedListsFilterStrategy(FilterList<T> filterList, IColumnAccessor<T> columnAccessor, IConfigRegistry configRegistry) {
            this.filterList = filterList;
            this.columnAccessor = columnAccessor;
            this.configRegistry = configRegistry;[b]
            this.matcherEditor = new CompositeMatcherEditor<T>();
    
            try {
                filterList.setMatcherEditor(matcherEditor);
            } catch (Exception e) {
                System.err.println(e.getMessage());
            }[/b]
        }
    

    …that way, we only apply obliterate the filtering on the filterlist once, on construction.

    The applyFilter and getMatcherEditor methods now look as follows (the commented out code should probably be deleted before you check your changes in, I have included them here to demonstrate where code has been removed):

        /**
         * Create GlazedLsists matcher editors and apply them to facilitate filtering.
         */
        public void applyFilter(Map<Integer, String> filterTextByIndex) {
            try {[b]
    //          filterList.setMatcherEditor(getMatcherEditor(filterTextByIndex));
                getMatcherEditor(filterTextByIndex);[/b]
            } catch (Exception e) {
                System.err.println(e.getMessage());
            }
        }
    
        protected MatcherEditor<T> getMatcherEditor(Map<Integer, String> filterTextByIndex) {[b]
    //      if (filterTextByIndex.isEmpty()) {
    //          return null;
    //      }[/b]
    
            EventList<MatcherEditor<T>> matcherEditors = new BasicEventList<MatcherEditor<T>>();
            for (Entry<Integer, String> mapEntry : filterTextByIndex.entrySet()) {
                Integer columnIndex = mapEntry.getKey();
                String filterText = mapEntry.getValue();
                List<ParseResult> parseResults = FilterRowUtils.parse(filterText);
                EventList<MatcherEditor<T>> stringMatcherEditors = new BasicEventList<MatcherEditor<T>>();
                for (ParseResult parseResult : parseResults)
                {
                    if (parseResult.getMatchOperation() == MatchType.NONE) {
                        stringMatcherEditors.add(getTextMatcherEditor(columnIndex, parseResult.getValuetoMatch()));
                    } else {
                        matcherEditors.add(getThresholdMatcherEditor(columnIndex, parseResult));
                    }
                }
    
                if (stringMatcherEditors.size()>0){
                    CompositeMatcherEditor<T> stringCompositeMatcherEditor = new CompositeMatcherEditor<T>(stringMatcherEditors);
                    stringCompositeMatcherEditor.setMode(CompositeMatcherEditor.OR);
                    matcherEditors.add(stringCompositeMatcherEditor);
                }
            }
            [b]
    //      CompositeMatcherEditor<T> matcherEditor = new CompositeMatcherEditor<T>(matcherEditors);
    //      matcherEditor.setMode(CompositeMatcherEditor.AND);
    
            matcherEditor.getMatcherEditors().clear();
            matcherEditor.getMatcherEditors().addAll(matcherEditors);
            matcherEditor.setMode(CompositeMatcherEditor.AND);      
            [/b]
            return matcherEditor;
        }
    

    You may wish to rename these methods or simply collapse them into one "applyFilter" method. I shall leave this to your judgement.

    Before I conclude, I just wanted to have a chat about the following lines of code:

                if (filterList.size() > 0) {
                // Attempt a comparison to ensure that the correct comparator has been set up.
                // An invalid comparator will leave the FilterList in an invalid state.
                try {
                    comparator.compare(columnValueProvider.evaluate(filterList.get(0)), displayConverter.displayToCanonicalValue(parseResult.getValuetoMatch()));
                } catch (Exception e) {
                    e.printStackTrace(System.err);
                    throw new IllegalStateException("Filter row layer not setup properly for threshold comparision on column: " +  columnIndex + ".\n" +
                            "Ensure that the correct display converter is registered via the FILTER_DISPLAY_CONVERTER attribute.\n" +
                            "Ensure that the correct comparator is registered via the FILTER_COMPARATOR attribute.\n");
                }
            }
    

    I can see that this code is definitely required as, if I put a nonsense filter expression in a numeric field, the filterlist never recovers as per your comments. However, given the motivation above, I'm wondering whether there is an alternative. In fact, the filter test is not necessarily reliable as the following steps will still destroy the filterlist:

    1) run the FilterRowGridExample
    2) In the "Bid" column filter, enter ">1000" (this should return no rows)
    3) Now edit that filter and type something like ">abcdef"
    4) Now the filters are messed up, nothing can bring them back to a working state

    This issue is not urgent for now, and I can think of some work-arounds, but I was wondering whether you had any ideas to approach this, in a clean and failsafe way?

    Many thanks for all your hard work in helping me with nattables.

    Adealex.

     
  • Edwin Park

    Edwin Park - 2011-01-10

    Hi Adealex,

    I checked in the patch to DefaultGlazedListsFilterStrategy - thanks! I tried the case you mentioned regarding entering an incorrect filter, but it looks like I am able to restore it to a working state by simply fixing the filter. I'm not sure if this is a result of the recent patches.

    Cheers,
    Edwin

     
  • adealex

    adealex - 2011-01-10

    Hi Edwin,

    Many thanks for going through my patch and committing it to trunk. In one of our other discussions I requested a change to the ThresholdMatcherEditor so that if a match fails, the exception is contained and doesn't corrupt the filtering on the GlazedList. I believe this removes the need for us to try the filter on the first element. My other small concern is that this class is just a temporary one until the code is fixed in GlazedList:

    ThresholdMatcherEditor:

     /**
      * TODO: THIS CLASS HAS BEEN COPIED FROM THE GLAZED LIST CODE AND MODIFIED
      * DUE TO A BUG IN THIS CLASS. ONCE THE BUG IS ADDRESSED FALL BACK TO THE
      * ORIGINAL IMPLEMENTATION
      * SEE: http://www.nabble.com/Bug-in-ThresholdMatcherEditor-td25757033.html
      */
    

    So this may be something we should watch when people move versions of GlazedLists.

    I mentioned earlier in this thread about the end-goal of having autofilters living side-by-side with other possible filters in the application layer. I now have the code for this and involves the modification of the following files:

    DefaultGlazedListsFilterStrategy.java
    FilterRowExampleGridLayer.java
    FullFeaturedColumnHeaderLayerStack.java
    ColumnHeaderLayerStack.java

    Here are the code changes (I have indicated code to be deleted by commenting it out, feel free to delete when applying these patches).

    DefaultGlazedListsFilterStrategy.java:

    public class DefaultGlazedListsFilterStrategy<T> implements IFilterStrategy<T> {
    [b]
    //  private final FilterList<T> filterList;[/b]
        private final IColumnAccessor<T> columnAccessor;
        private final IConfigRegistry configRegistry;
        private final CompositeMatcherEditor<T> matcherEditor;
    
    [b]
        public DefaultGlazedListsFilterStrategy(CompositeMatcherEditor<T> matcherEditor, IColumnAccessor<T> columnAccessor, IConfigRegistry configRegistry) {
    //      this.filterList = filterList;[/b]
            this.columnAccessor = columnAccessor;
            this.configRegistry = configRegistry;
            this.matcherEditor = matcherEditor;
            [b]
    //      try {
    //          filterList.setMatcherEditor(matcherEditor);
    //      } catch (Exception e) {
    //          System.err.println(e.getMessage());
    //      }[/b]
        }
        /**
         * Create GlazedLsists matcher editors and apply them to facilitate filtering.
         */
        public void applyFilter(Map<Integer, String> filterTextByIndex) {
            try {
                matcherEditor.getMatcherEditors().clear();
    
                if (filterTextByIndex.isEmpty()) {
                    return;
                }
    
                EventList<MatcherEditor<T>> matcherEditors = new BasicEventList<MatcherEditor<T>>();
                for (Entry<Integer, String> mapEntry : filterTextByIndex.entrySet()) {
                    Integer columnIndex = mapEntry.getKey();
                    String filterText = mapEntry.getValue();
                    List<ParseResult> parseResults = FilterRowUtils.parse(filterText);
                    EventList<MatcherEditor<T>> stringMatcherEditors = new BasicEventList<MatcherEditor<T>>();
                    for (ParseResult parseResult : parseResults)
                    {
                        if (parseResult.getMatchOperation() == MatchType.NONE) {
                            stringMatcherEditors.add(getTextMatcherEditor(columnIndex, parseResult.getValuetoMatch()));
                        } else {
                            matcherEditors.add(getThresholdMatcherEditor(columnIndex, parseResult));
                        }
                    }
    
                    if (stringMatcherEditors.size()>0){
                        CompositeMatcherEditor<T> stringCompositeMatcherEditor = new CompositeMatcherEditor<T>(stringMatcherEditors);
                        stringCompositeMatcherEditor.setMode(CompositeMatcherEditor.OR);
                        matcherEditors.add(stringCompositeMatcherEditor);
                    }
                }
    
                matcherEditor.getMatcherEditors().addAll(matcherEditors);
                matcherEditor.setMode(CompositeMatcherEditor.AND);
            } catch (Exception e) {
                System.err.println(e.getMessage());
            }
        }
        /**
         * Set up a threshold matcher for tokens like '>20', '<=10' etc.
         * @param columnIndex of the column for which the matcher editor is being set up
         */
        @SuppressWarnings("unchecked")
        protected ThresholdMatcherEditor<T, Object> getThresholdMatcherEditor(Integer columnIndex, ParseResult parseResult) {
            Comparator comparator = configRegistry.getConfigAttribute(
                    FILTER_COMPARATOR, NORMAL, FILTER_ROW_COLUMN_LABEL_PREFIX + columnIndex);
            IDisplayConverter displayConverter = configRegistry.getConfigAttribute(
                    FILTER_DISPLAY_CONVERTER, NORMAL, FILTER_ROW_COLUMN_LABEL_PREFIX + columnIndex);
            final Function<T, Object> columnValueProvider = getColumnValueProvider(columnIndex);
            [b]
    //      if (filterList.size() > 0) {
    //          // Attempt a comparison to ensure that the correct comparator has been set up.
    //          // An invalid comparator will leave the FilterList in an invalid state.
    //          try {
    //              comparator.compare(columnValueProvider.evaluate(filterList.get(0)), displayConverter.displayToCanonicalValue(parseResult.getValuetoMatch()));
    //          } catch (Exception e) {
    //              e.printStackTrace(System.err);
    //              throw new IllegalStateException("Filter row layer not setup properly for threshold comparision on column: " +  columnIndex + ".\n" +
    //                      "Ensure that the correct display converter is registered via the FILTER_DISPLAY_CONVERTER attribute.\n" +
    //                      "Ensure that the correct comparator is registered via the FILTER_COMPARATOR attribute.\n");
    //          }
    //      }
            [/b]
            ThresholdMatcherEditor<T, Object> thresholdMatcherEditor = new ThresholdMatcherEditor<T, Object>(
                    displayConverter.displayToCanonicalValue(parseResult.getValuetoMatch()),
                    null,
                    comparator,
                    columnValueProvider);
            FilterRowUtils.setMatchOperation(thresholdMatcherEditor, parseResult.getMatchOperation());
            return thresholdMatcherEditor;
        }
        /**
         * @return Function which exposes the content of the given column index from the row object
         */
        protected FunctionList.Function<T, Object> getColumnValueProvider(final int columnIndex) {
            return new FunctionList.Function<T, Object>() {
                public Object evaluate(T rowObject) {
                    return columnAccessor.getDataValue(rowObject, columnIndex);
                }
            };
        }
        /**
         * Sets up a text matcher editor for String tokens
         * @param columnIndex of the column for which the matcher editor is being set up
         * @param filterText text entered by the user in the filter row
         */
        protected TextMatcherEditor<T> getTextMatcherEditor(Integer columnIndex, String filterText) {
            TextMatchingMode textMatchingMode = configRegistry.getConfigAttribute(
                    TEXT_MATCHING_MODE, NORMAL, FILTER_ROW_COLUMN_LABEL_PREFIX + columnIndex);
            TextMatcherEditor<T> textMatcherEditor = new TextMatcherEditor<T>(getTextFilterator(columnIndex));
            textMatcherEditor.setFilterText(new String[] { filterText });
            textMatcherEditor.setMode(getGlazedListsTextMatcherEditorMode(textMatchingMode));
            return textMatcherEditor;
        }
        /**
         * @return {@link TextFilterator} which exposes the contents of the column as a {@link String}
         */
        protected TextFilterator<T> getTextFilterator(final Integer columnIndex) {
            final IDisplayConverter converter = configRegistry.getConfigAttribute(
                    FILTER_DISPLAY_CONVERTER, NORMAL, FILTER_ROW_COLUMN_LABEL_PREFIX + columnIndex);
            return new TextFilterator<T>() {
                public void getFilterStrings(List<String> objectAsListOfStrings, T rowObject) {
                    Object cellData = columnAccessor.getDataValue(rowObject, columnIndex);
                    Object displayValue = converter.canonicalToDisplayValue(cellData);
                    displayValue = isNotNull(displayValue) ? displayValue : "";
                    objectAsListOfStrings.add(displayValue.toString());
                }
            };
        }
        /**
         * @return the equivalent for GlazedLists TextMatcherEditor.
         */
        public int getGlazedListsTextMatcherEditorMode(TextMatchingMode textMatchingMode) {
            switch (textMatchingMode) {
            case STARTS_WITH:
                return TextMatcherEditor.STARTS_WITH;
            case REGULAR_EXPRESSION:
                return TextMatcherEditor.REGULAR_EXPRESSION;
            default:
                return TextMatcherEditor.CONTAINS;
            }
        }
    
    }
    

    FilterRowExampleGridLayer.java:

            //  Note: The column header layer is wrapped in a filter row composite.
            //  This plugs in the filter row functionality[b]
            CompositeMatcherEditor<RowDataFixture> autoFilterMatcherEditor = new CompositeMatcherEditor<RowDataFixture>();
            filterList.setMatcherEditor(autoFilterMatcherEditor);[/b]
    
            FilterRowHeaderComposite<RowDataFixture> filterRowHeaderLayer =
                new FilterRowHeaderComposite<RowDataFixture>([b]
                        new DefaultGlazedListsFilterStrategy<RowDataFixture>(autoFilterMatcherEditor, columnPropertyAccessor, configRegistry),[/b]
                        columnHeaderLayer
                );
    

    FullFeaturedColumnHeaderLayerStack.java:

    [b]
            CompositeMatcherEditor<T> autoFilterMatcherEditor = new CompositeMatcherEditor<T>();
            filterList.setMatcherEditor(autoFilterMatcherEditor);       
    [/b]
            FilterRowHeaderComposite<T> composite =
                new FilterRowHeaderComposite<T>(
                        new DefaultGlazedListsFilterStrategy<T>([b]
                                autoFilterMatcherEditor,[/b]
                                columnPropertyAccessor,
                                configRegistry
                        ),
                        sortableColumnHeaderLayer);
            setUnderlyingLayer(composite);
        }
    

    ColumnHeaderLayerStack.java:

            if(tableModel.enableFilterRow){[b]
                CompositeMatcherEditor<T> autoFilterMatcherEditor = new CompositeMatcherEditor<T>();
                filterList.setMatcherEditor(autoFilterMatcherEditor);   [/b]
    
                filterRowHeaderLayer =
                    new FilterRowHeaderComposite<T>(
                            new DefaultGlazedListsFilterStrategy<T>([b]
                                    autoFilterMatcherEditor,[/b]
                                    columnAccessor,
                                    configRegistry
                            ),
                            sortableColumnHeaderLayer
                    );
                setUnderlyingLayer(filterRowHeaderLayer);
            } else {
    

    Many thanks and welcome back from your vacation.

    Adealex.

     
  • Edwin Park

    Edwin Park - 2011-01-19

    I have applied these changes. Thanks Adealex!

     
  • adealex

    adealex - 2011-01-19

    I can see these changes are now checked in. Many thanks.

     

Log in to post a comment.