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 d1b7925..13f7d31 100644 (file)
                OO.EmitterList.call( this );
 
                this.groups = {};
-               this.excludedByMap = {};
                this.defaultParams = {};
                this.defaultFiltersEmpty = null;
 
                // Events
                this.aggregate( { update: 'filterItemUpdate' } );
-               this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } );
+               this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
        };
 
        /* Initialization */
        /* Methods */
 
        /**
-        * Respond to filter item change.
+        * Re-assess the states of filter items based on the interactions between them
         *
-        * @param {mw.rcfilters.dm.FilterItem} item Updated filter
-        * @fires itemUpdate
+        * @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.onFilterItemUpdate = function ( item ) {
-               // Reapply the active state of filters
-               this.reapplyActiveFilters( item );
+       mw.rcfilters.dm.FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
+               var allSelected,
+                       model = this,
+                       iterationItems = item !== undefined ? [ item ] : this.getItems();
 
-               // Recheck group activity state
-               this.getGroup( item.getGroup() ).checkActive();
+               iterationItems.forEach( function ( checkedItem ) {
+                       var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
+                               groupModel = checkedItem.getGroupModel();
 
-               this.emit( 'itemUpdate', item );
-       };
+                       // Check for subsets (included filters) plus the item itself:
+                       allCheckedItems.forEach( function ( filterItemName ) {
+                               var itemInSubset = model.getItemByName( filterItemName );
 
-       /**
-        * Calculate the active state of the filters, based on selected filters in the group.
-        *
-        * @param {mw.rcfilters.dm.FilterItem} item Changed item
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.reapplyActiveFilters = function ( item ) {
-               var selectedItemsCount,
-                       group = item.getGroup(),
-                       model = this;
-               if (
-                       !this.getGroup( group ).getExclusionType() ||
-                       this.getGroup( group ).getExclusionType() === 'default'
-               ) {
-                       // Default behavior
-                       // If any parameter is selected, but:
-                       // - If there are unselected items in the group, they are inactive
-                       // - If the entire group is selected, all are inactive
-
-                       // Check what's selected in the group
-                       selectedItemsCount = this.getGroupFilters( group ).filter( function ( filterItem ) {
-                               return filterItem.isSelected();
-                       } ).length;
-
-                       this.getGroupFilters( group ).forEach( function ( filterItem ) {
-                               filterItem.toggleActive(
-                                       selectedItemsCount > 0 ?
-                                               // If some items are selected
-                                               (
-                                                       selectedItemsCount === model.groups[ group ].getItemCount() ?
-                                                       // If **all** items are selected, they're all inactive
-                                                       false :
-                                                       // If not all are selected, then the selected are active
-                                                       // and the unselected are inactive
-                                                       filterItem.isSelected()
-                                               ) :
-                                               // No item is selected, everything is active
-                                               true
+                               itemInSubset.toggleIncluded(
+                                       // If any of itemInSubset's supersets are selected, this item
+                                       // is included
+                                       itemInSubset.getSuperset().some( function ( supersetName ) {
+                                               return ( model.getItemByName( supersetName ).isSelected() );
+                                       } )
                                );
                        } );
-               } else if ( this.getGroup( group ).getExclusionType() === 'explicit' ) {
-                       // Explicit behavior
-                       // - Go over the list of excluded filters to change their
-                       //   active states accordingly
-
-                       // For each item in the list, see if there are other selected
-                       // filters that also exclude it. If it does, it will still be
-                       // inactive.
-
-                       item.getExcludedFilters().forEach( function ( filterName ) {
-                               var filterItem = model.getItemByName( filterName );
-
-                               // Note to reduce confusion:
-                               // - item is the filter whose state changed and should exclude the other filters
-                               //   in its list of exclusions
-                               // - filterItem is the filter that is potentially being excluded by the current item
-                               // - anotherExcludingFilter is any other filter that excludes filterItem; we must check
-                               //   if that filter is selected, because if it is, we should not touch the excluded item
-                               if (
-                                       // Check if there are any filters (other than the current one)
-                                       // that also exclude the filterName
-                                       !model.excludedByMap[ filterName ].some( function ( anotherExcludingFilterName ) {
-                                               var anotherExcludingFilter = model.getItemByName( anotherExcludingFilterName );
-
-                                               return (
-                                                       anotherExcludingFilterName !== item.getName() &&
-                                                       anotherExcludingFilter.isSelected()
-                                               );
-                                       } )
-                               ) {
-                                       // Only change the state for filters that aren't
-                                       // also affected by other excluding selected filters
-                                       filterItem.toggleActive( !item.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 );
+               } );
        };
 
        /**
         * @param {Object} filters Filter group definition
         */
        mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters ) {
-               var i, filterItem, selectedFilterNames, excludedFilters,
+               var i, filterItem, selectedFilterNames,
                        model = this,
                        items = [],
-                       addToMap = function ( excludedFilters ) {
-                               excludedFilters.forEach( function ( filterName ) {
-                                       model.excludedByMap[ filterName ] = model.excludedByMap[ filterName ] || [];
-                                       model.excludedByMap[ filterName ].push( filterItem.getName() );
+                       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 = {};
-               this.excludedByMap = {};
 
                $.each( filters, function ( group, data ) {
                        if ( !model.groups[ group ] ) {
-                               model.groups[ group ] = new mw.rcfilters.dm.FilterGroup( {
+                               model.groups[ group ] = new mw.rcfilters.dm.FilterGroup( group, {
                                        type: data.type,
                                        title: data.title,
                                        separator: data.separator,
-                                       exclusionType: data.exclusionType
+                                       fullCoverage: !!data.fullCoverage
                                } );
                        }
 
                        selectedFilterNames = [];
                        for ( i = 0; i < data.filters.length; i++ ) {
-                               excludedFilters = data.filters[ i ].excludes || [];
-
-                               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,
-                                       excludes: excludedFilters,
-                                       'default': data.filters[ i ].default
+                                       subset: data.filters[ i ].subset
                                } );
 
-                               // Map filters and what excludes them
-                               addToMap( excludedFilters );
+                               // 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
                        }
                } );
 
+               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' );
                return this.groups;
        };
 
-       /**
-        * Update the representation of the parameters. These are the back-end
-        * parameters representing the filters, but they represent the given
-        * current state regardless of validity.
-        *
-        * This should only run after filters are already set.
-        *
-        * @param {Object} params Parameter state
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.updateParameters = function ( params ) {
-               var model = this;
-
-               $.each( params, function ( name, value ) {
-                       // Only store the parameters that exist in the system
-                       if ( model.getItemByName( name ) ) {
-                               model.parameters[ name ] = value;
-                       }
-               } );
-       };
-
        /**
         * Get the value of a specific parameter
         *
                for ( i = 0; i < items.length; i++ ) {
                        result[ items[ i ].getName() ] = {
                                selected: items[ i ].isSelected(),
-                               active: items[ i ].isActive()
+                               conflicted: items[ i ].isConflicted(),
+                               included: items[ i ].isIncluded()
                        };
                }
 
                        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'
        /**
         * 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;
        };