Merge "Upstream change to message 'summary-preview' from en.wiki"
[lhc/web/wiklou.git] / resources / src / mediawiki.rcfilters / dm / mw.rcfilters.dm.FiltersViewModel.js
index 3217d0d..13f7d31 100644 (file)
                OO.EmitterList.call( this );
 
                this.groups = {};
+               this.defaultParams = {};
+               this.defaultFiltersEmpty = null;
 
                // Events
-               this.aggregate( { update: 'itemUpdate' } );
+               this.aggregate( { update: 'filterItemUpdate' } );
+               this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
        };
 
        /* Initialization */
 
        /* Methods */
 
+       /**
+        * Re-assess the states of filter items based on the interactions between them
+        *
+        * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
+        *  method will go over the state of all items
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
+               var allSelected,
+                       model = this,
+                       iterationItems = item !== undefined ? [ item ] : this.getItems();
+
+               iterationItems.forEach( function ( checkedItem ) {
+                       var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
+                               groupModel = checkedItem.getGroupModel();
+
+                       // Check for subsets (included filters) plus the item itself:
+                       allCheckedItems.forEach( function ( filterItemName ) {
+                               var itemInSubset = model.getItemByName( filterItemName );
+
+                               itemInSubset.toggleIncluded(
+                                       // If any of itemInSubset's supersets are selected, this item
+                                       // is included
+                                       itemInSubset.getSuperset().some( function ( supersetName ) {
+                                               return ( model.getItemByName( supersetName ).isSelected() );
+                                       } )
+                               );
+                       } );
+
+                       // Update coverage for the changed group
+                       if ( groupModel.isFullCoverage() ) {
+                               allSelected = groupModel.areAllSelected();
+                               groupModel.getItems().forEach( function ( filterItem ) {
+                                       filterItem.toggleFullyCovered( allSelected );
+                               } );
+                       }
+               } );
+
+               // Check for conflicts
+               // In this case, we must go over all items, since
+               // conflicts are bidirectional and depend not only on
+               // individual items, but also on the selected states of
+               // the groups they're in.
+               this.getItems().forEach( function ( filterItem ) {
+                       var inConflict = false,
+                               filterItemGroup = filterItem.getGroupModel();
+
+                       // For each item, see if that item is still conflicting
+                       $.each( model.groups, function ( groupName, groupModel ) {
+                               if ( filterItem.getGroupName() === groupName ) {
+                                       // Check inside the group
+                                       inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
+                               } else {
+                                       // According to the spec, if two items conflict from two different
+                                       // groups, the conflict only lasts if the groups **only have selected
+                                       // items that are conflicting**. If a group has selected items that
+                                       // are conflicting and non-conflicting, the scope of the result has
+                                       // expanded enough to completely remove the conflict.
+
+                                       // For example, see two groups with conflicts:
+                                       // userExpLevel: [
+                                       //   {
+                                       //      name: 'experienced',
+                                       //      conflicts: [ 'unregistered' ]
+                                       //   }
+                                       // ],
+                                       // registration: [
+                                       //   {
+                                       //      name: 'registered',
+                                       //   },
+                                       //   {
+                                       //      name: 'unregistered',
+                                       //   }
+                                       // ]
+                                       // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
+                                       // because, inherently, 'experienced' filter only includes registered users, and so
+                                       // both filters are in conflict with one another.
+                                       // However, the minute we select 'registered', the scope of our results
+                                       // has expanded to no longer have a conflict with 'experienced' filter, and
+                                       // so the conflict is removed.
+
+                                       // In our case, we need to check if the entire group conflicts with
+                                       // the entire item's group, so we follow the above spec
+                                       inConflict = (
+                                               // The foreign group is in conflict with this item
+                                               groupModel.areAllSelectedInConflictWith( filterItem ) &&
+                                               // Every selected member of the item's own group is also
+                                               // in conflict with the other group
+                                               filterItemGroup.getSelectedItems().every( function ( otherGroupItem ) {
+                                                       return groupModel.areAllSelectedInConflictWith( otherGroupItem );
+                                               } )
+                                       );
+                               }
+
+                               // If we're in conflict, this will return 'false' which
+                               // will break the loop. Otherwise, we're not in conflict
+                               // and the loop continues
+                               return !inConflict;
+                       } );
+
+                       // Toggle the item state
+                       filterItem.toggleConflicted( inConflict );
+               } );
+       };
+
        /**
         * Set filters and preserve a group relationship based on
         * the definition given by an object
         * @param {Object} filters Filter group definition
         */
        mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters ) {
-               var i, filterItem,
+               var i, filterItem, selectedFilterNames,
                        model = this,
-                       items = [];
+                       items = [],
+                       addArrayElementsUnique = function ( arr, elements ) {
+                               elements = Array.isArray( elements ) ? elements : [ elements ];
+
+                               elements.forEach( function ( element ) {
+                                       if ( arr.indexOf( element ) === -1 ) {
+                                               arr.push( element );
+                                       }
+                               } );
+
+                               return arr;
+                       },
+                       conflictMap = {},
+                       supersetMap = {};
 
                // Reset
                this.clearItems();
                this.groups = {};
 
                $.each( filters, function ( group, data ) {
-                       model.groups[ group ] = model.groups[ group ] || {};
-                       model.groups[ group ].filters = model.groups[ group ].filters || [];
-
-                       model.groups[ group ].title = data.title;
-                       model.groups[ group ].type = data.type;
-                       model.groups[ group ].separator = data.separator || '|';
+                       if ( !model.groups[ group ] ) {
+                               model.groups[ group ] = new mw.rcfilters.dm.FilterGroup( group, {
+                                       type: data.type,
+                                       title: data.title,
+                                       separator: data.separator,
+                                       fullCoverage: !!data.fullCoverage
+                               } );
+                       }
 
+                       selectedFilterNames = [];
                        for ( i = 0; i < data.filters.length; i++ ) {
-                               filterItem = new mw.rcfilters.dm.FilterItem( data.filters[ i ].name, {
+                               filterItem = new mw.rcfilters.dm.FilterItem( data.filters[ i ].name, model.groups[ group ], {
                                        group: group,
                                        label: data.filters[ i ].label,
                                        description: data.filters[ i ].description,
-                                       selected: data.filters[ i ].selected
+                                       subset: data.filters[ i ].subset
                                } );
 
-                               model.groups[ group ].filters.push( filterItem );
+                               // For convenience, we should store each filter's "supersets" -- these are
+                               // the filters that have that item in their subset list. This will just
+                               // make it easier to go through whether the item has any other items
+                               // that affect it (and are selected) at any given time
+                               if ( data.filters[ i ].subset ) {
+                                       data.filters[ i ].subset.forEach( function ( subsetFilterName ) { // eslint-disable-line no-loop-func
+                                               supersetMap[ subsetFilterName ] = supersetMap[ subsetFilterName ] || [];
+                                               addArrayElementsUnique(
+                                                       supersetMap[ subsetFilterName ],
+                                                       filterItem.getName()
+                                               );
+                                       } );
+                               }
+
+                               // Conflicts are bi-directional, which means FilterA can define having
+                               // a conflict with FilterB, and this conflict should appear in **both**
+                               // filter definitions.
+                               // We need to remap all the 'conflicts' so they reflect the entire state
+                               // in either direction regardless of which filter defined the other as conflicting.
+                               if ( data.filters[ i ].conflicts ) {
+                                       conflictMap[ filterItem.getName() ] = conflictMap[ filterItem.getName() ] || [];
+                                       addArrayElementsUnique(
+                                               conflictMap[ filterItem.getName() ],
+                                               data.filters[ i ].conflicts
+                                       );
+
+                                       data.filters[ i ].conflicts.forEach( function ( conflictingFilterName ) { // eslint-disable-line no-loop-func
+                                               // Add this filter to the conflicts of each of the filters in its list
+                                               conflictMap[ conflictingFilterName ] = conflictMap[ conflictingFilterName ] || [];
+                                               addArrayElementsUnique(
+                                                       conflictMap[ conflictingFilterName ],
+                                                       filterItem.getName()
+                                               );
+                                       } );
+                               }
+
+                               if ( data.type === 'send_unselected_if_any' ) {
+                                       // Store the default parameter state
+                                       // For this group type, parameter values are direct
+                                       model.defaultParams[ data.filters[ i ].name ] = Number( !!data.filters[ i ].default );
+                               } else if (
+                                       data.type === 'string_options' &&
+                                       data.filters[ i ].default
+                               ) {
+                                       selectedFilterNames.push( data.filters[ i ].name );
+                               }
+
+                               model.groups[ group ].addItems( filterItem );
                                items.push( filterItem );
                        }
+
+                       if ( data.type === 'string_options' ) {
+                               // Store the default parameter group state
+                               // For this group, the parameter is group name and value is the names
+                               // of selected items
+                               model.defaultParams[ group ] = model.sanitizeStringOptionGroup( group, selectedFilterNames ).join( model.groups[ group ].getSeparator() );
+                       }
+               } );
+
+               items.forEach( function ( filterItem ) {
+                       // Apply conflict map to the items
+                       // Now that we mapped all items and conflicts bi-directionally
+                       // we need to apply the definition to each filter again
+                       filterItem.setConflicts( conflictMap[ filterItem.getName() ] );
+
+                       // Apply the superset map
+                       filterItem.setSuperset( supersetMap[ filterItem.getName() ] );
                } );
 
+               // Add items to the model
                this.addItems( items );
+
                this.emit( 'initialize' );
        };
 
        };
 
        /**
-        * Get the object that defines groups and their filter items.
-        * The structure of this response:
-        * {
-        *   groupName: {
-        *     title: {string} Group title
-        *     type: {string} Group type
-        *     filters: {string[]} Filters in the group
-        *   }
-        * }
+        * Get the object that defines groups by their name.
         *
         * @return {Object} Filter groups
         */
        };
 
        /**
-        * Get the current state of the filters
+        * Get the value of a specific parameter
         *
-        * @return {Object} Filters current state
+        * @param {string} name Parameter name
+        * @return {number|string} Parameter value
         */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getState = function () {
+       mw.rcfilters.dm.FiltersViewModel.prototype.getParamValue = function ( name ) {
+               return this.parameters[ name ];
+       };
+
+       /**
+        * Get the current selected state of the filters
+        *
+        * @return {Object} Filters selected state
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getSelectedState = function () {
                var i,
                        items = this.getItems(),
                        result = {};
                return result;
        };
 
+       /**
+        * Get the current full state of the filters
+        *
+        * @return {Object} Filters full state
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getFullState = function () {
+               var i,
+                       items = this.getItems(),
+                       result = {};
+
+               for ( i = 0; i < items.length; i++ ) {
+                       result[ items[ i ].getName() ] = {
+                               selected: items[ i ].isSelected(),
+                               conflicted: items[ i ].isConflicted(),
+                               included: items[ i ].isIncluded()
+                       };
+               }
+
+               return result;
+       };
+
+       /**
+        * Get the default parameters object
+        *
+        * @return {Object} Default parameter values
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultParams = function () {
+               return this.defaultParams;
+       };
+
+       /**
+        * Set all filter states to default values
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.setFiltersToDefaults = function () {
+               var defaultFilterStates = this.getFiltersFromParameters( this.getDefaultParams() );
+
+               this.updateFilters( defaultFilterStates );
+       };
+
        /**
         * Analyze the groups and their filters and output an object representing
         * the state of the parameters they represent.
         *
+        * @param {Object} [filterGroups] An object defining the filter groups to
+        *  translate to parameters. Its structure must follow that of this.groups
+        *  see #getFilterGroups
         * @return {Object} Parameter state object
         */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function () {
+       mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function ( filterGroups ) {
                var i, filterItems, anySelected, values,
                        result = {},
-                       groupItems = this.getFilterGroups();
+                       groupItems = filterGroups || this.getFilterGroups();
 
-               $.each( groupItems, function ( group, data ) {
-                       filterItems = data.filters;
+               $.each( groupItems, function ( group, model ) {
+                       filterItems = model.getItems();
 
-                       if ( data.type === 'send_unselected_if_any' ) {
+                       if ( model.getType() === 'send_unselected_if_any' ) {
                                // First, check if any of the items are selected at all.
                                // If none is selected, we're treating it as if they are
                                // all false
                                        result[ filterItems[ i ].getName() ] = anySelected ?
                                                Number( !filterItems[ i ].isSelected() ) : 0;
                                }
-                       } else if ( data.type === 'string_options' ) {
+                       } else if ( model.getType() === 'string_options' ) {
                                values = [];
                                for ( i = 0; i < filterItems.length; i++ ) {
                                        if ( filterItems[ i ].isSelected() ) {
                                if ( values.length === 0 || values.length === filterItems.length ) {
                                        result[ group ] = 'all';
                                } else {
-                                       result[ group ] = values.join( data.separator );
+                                       result[ group ] = values.join( model.getSeparator() );
                                }
                        }
                } );
         * Remove duplicates and make sure to only use valid
         * values.
         *
+        * @private
         * @param {string} groupName Group name
         * @param {string[]} valueArray Array of values
         * @return {string[]} Array of valid values
         */
        mw.rcfilters.dm.FiltersViewModel.prototype.sanitizeStringOptionGroup = function( groupName, valueArray ) {
                var result = [],
-                       validNames = this.groups[ groupName ].filters.map( function ( filterItem ) {
+                       validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
                                return filterItem.getName();
                        } );
 
                return result;
        };
 
+       /**
+        * Check whether the current filter state is set to all false.
+        *
+        * @return {boolean} Current filters are all empty
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.areCurrentFiltersEmpty = function () {
+               var currFilters = this.getSelectedState();
+
+               return Object.keys( currFilters ).every( function ( filterName ) {
+                       return !currFilters[ filterName ];
+               } );
+       };
+
+       /**
+        * Check whether the default values of the filters are all false.
+        *
+        * @return {boolean} Default filters are all false
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.areDefaultFiltersEmpty = function () {
+               var defaultFilters;
+
+               if ( this.defaultFiltersEmpty !== null ) {
+                       // We only need to do this test once,
+                       // because defaults are set once per session
+                       defaultFilters = this.getFiltersFromParameters();
+                       this.defaultFiltersEmpty = Object.keys( defaultFilters ).every( function ( filterName ) {
+                               return !defaultFilters[ filterName ];
+                       } );
+               }
+
+               return this.defaultFiltersEmpty;
+       };
+
        /**
         * This is the opposite of the #getParametersFromFilters method; this goes over
-        * the parameters and translates into a selected/unselected value in the filters.
+        * the given parameters and translates into a selected/unselected value in the filters.
         *
         * @param {Object} params Parameters query object
         * @return {Object} Filter state object
                var i, filterItem,
                        groupMap = {},
                        model = this,
-                       base = this.getParametersFromFilters(),
-                       // Start with current state
-                       result = this.getState();
+                       base = this.getDefaultParams(),
+                       result = {};
 
                params = $.extend( {}, base, params );
 
                        filterItem = model.getItemByName( paramName );
                        // Ignore if no filter item exists
                        if ( filterItem ) {
-                               groupMap[ filterItem.getGroup() ] = groupMap[ filterItem.getGroup() ] || {};
+                               groupMap[ filterItem.getGroupName() ] = groupMap[ filterItem.getGroupName() ] || {};
 
                                // Mark the group if it has any items that are selected
-                               groupMap[ filterItem.getGroup() ].hasSelected = (
-                                       groupMap[ filterItem.getGroup() ].hasSelected ||
+                               groupMap[ filterItem.getGroupName() ].hasSelected = (
+                                       groupMap[ filterItem.getGroupName() ].hasSelected ||
                                        !!Number( paramValue )
                                );
 
                                // Add the relevant filter into the group map
-                               groupMap[ filterItem.getGroup() ].filters = groupMap[ filterItem.getGroup() ].filters || [];
-                               groupMap[ filterItem.getGroup() ].filters.push( filterItem );
+                               groupMap[ filterItem.getGroupName() ].filters = groupMap[ filterItem.getGroupName() ].filters || [];
+                               groupMap[ filterItem.getGroupName() ].filters.push( filterItem );
                        } else if ( model.groups.hasOwnProperty( paramName ) ) {
                                // This parameter represents a group (values are the filters)
                                // this is equivalent to checking if the group is 'string_options'
-                               groupMap[ paramName ] = { filters: model.groups[ paramName ].filters };
+                               groupMap[ paramName ] = { filters: model.groups[ paramName ].getItems() };
                        }
                } );
 
                        var paramValues, filterItem,
                                allItemsInGroup = data.filters;
 
-                       if ( model.groups[ group ].type === 'send_unselected_if_any' ) {
+                       if ( model.groups[ group ].getType() === 'send_unselected_if_any' ) {
                                for ( i = 0; i < allItemsInGroup.length; i++ ) {
                                        filterItem = allItemsInGroup[ i ];
 
                                                // group, which means the state is false
                                                false;
                                }
-                       } else if ( model.groups[ group ].type === 'string_options' ) {
-                               paramValues = model.sanitizeStringOptionGroup( group, params[ group ].split( model.groups[ group ].separator ) );
+                       } else if ( model.groups[ group ].getType() === 'string_options' ) {
+                               paramValues = model.sanitizeStringOptionGroup( group, params[ group ].split( model.groups[ group ].getSeparator() ) );
 
                                for ( i = 0; i < allItemsInGroup.length; i++ ) {
                                        filterItem = allItemsInGroup[ i ];
                                                        // If it is the word 'all'
                                                        paramValues.length === 1 && paramValues[ 0 ] === 'all' ||
                                                        // All values are written
-                                                       paramValues.length === model.groups[ group ].filters.length
+                                                       paramValues.length === model.groups[ group ].getItemCount()
                                                ) ?
                                                // All true (either because all values are written or the term 'all' is written)
                                                // is the same as all filters set to false
                } )[ 0 ];
        };
 
+       /**
+        * Set all filters to false or empty/all
+        * This is equivalent to display all.
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.emptyAllFilters = function () {
+               var filters = {};
+
+               this.getItems().forEach( function ( filterItem ) {
+                       filters[ filterItem.getName() ] = false;
+               } );
+
+               // Update filters
+               this.updateFilters( filters );
+       };
+
        /**
         * Toggle selected state of items by their names
         *
                }
        };
 
+       /**
+        * Get a group model from its name
+        *
+        * @param {string} groupName Group name
+        * @return {mw.rcfilters.dm.FilterGroup} Group model
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getGroup = function ( groupName ) {
+               return this.groups[ groupName ];
+       };
+
+       /**
+        * Get all filters within a specified group by its name
+        *
+        * @param {string} groupName Group name
+        * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
+               return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
+       };
+
        /**
         * Find items whose labels match the given string
         *
-        * @param {string} str Search string
+        * @param {string} query Search string
         * @return {Object} An object of items to show
         *  arranged by their group names
         */
-       mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( str ) {
+       mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( query ) {
                var i,
+                       groupTitle,
                        result = {},
                        items = this.getItems();
 
                // Normalize so we can search strings regardless of case
-               str = str.toLowerCase();
+               query = query.toLowerCase();
+
+               // item label starting with the query string
                for ( i = 0; i < items.length; i++ ) {
-                       if ( items[ i ].getLabel().toLowerCase().indexOf( str ) > -1 ) {
-                               result[ items[ i ].getGroup() ] = result[ items[ i ].getGroup() ] || [];
-                               result[ items[ i ].getGroup() ].push( items[ i ] );
+                       if ( items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ) {
+                               result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
+                               result[ items[ i ].getGroupName() ].push( items[ i ] );
+                       }
+               }
+
+               if ( $.isEmptyObject( result ) ) {
+                       // item containing the query string in their label, description, or group title
+                       for ( i = 0; i < items.length; i++ ) {
+                               groupTitle = items[ i ].getGroupModel().getTitle();
+                               if (
+                                       items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
+                                       items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
+                                       groupTitle.toLowerCase().indexOf( query ) > -1
+                               ) {
+                                       result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
+                                       result[ items[ i ].getGroupName() ].push( items[ i ] );
+                               }
                        }
                }
+
                return result;
        };