diff --git a/modules/backend/classes/FilterScope.php b/modules/backend/classes/FilterScope.php index d82cb1f81..bf5d062fb 100644 --- a/modules/backend/classes/FilterScope.php +++ b/modules/backend/classes/FilterScope.php @@ -51,6 +51,11 @@ class FilterScope */ public $options; + /** + * @var array Other scope names this scope depends on, when the other scopes are modified, this scope will update. + */ + public $dependsOn; + /** * @var string Specifies contextual visibility of this form scope. */ @@ -113,33 +118,32 @@ class FilterScope */ protected function evalConfig($config) { - if (isset($config['options'])) { - $this->options = $config['options']; + if ($config === null) { + $config = []; } - if (isset($config['context'])) { - $this->context = $config['context']; - } - if (isset($config['default'])) { - $this->defaults = $config['default']; - } - if (isset($config['conditions'])) { - $this->conditions = $config['conditions']; - } - if (isset($config['scope'])) { - $this->scope = $config['scope']; - } - if (isset($config['cssClass'])) { - $this->cssClass = $config['cssClass']; - } - if (isset($config['nameFrom'])) { - $this->nameFrom = $config['nameFrom']; - } - if (isset($config['descriptionFrom'])) { - $this->descriptionFrom = $config['descriptionFrom']; - } - if (array_key_exists('disabled', $config)) { - $this->disabled = $config['disabled']; + + /* + * Standard config:property values + */ + $applyConfigValues = [ + 'options', + 'dependsOn', + 'context', + 'default', + 'conditions', + 'scope', + 'cssClass', + 'nameFrom', + 'descriptionFrom', + 'disabled', + ]; + + foreach ($applyConfigValues as $value) { + if (array_key_exists($value, $config)) { + $this->{$value} = $config[$value]; + } } + return $config; } diff --git a/modules/backend/widgets/Filter.php b/modules/backend/widgets/Filter.php index 5deb97210..738b81232 100644 --- a/modules/backend/widgets/Filter.php +++ b/modules/backend/widgets/Filter.php @@ -171,6 +171,22 @@ class Filter extends WidgetBase return $this->makePartial('scope_'.$scope->type, $params); } + /** + * Returns a HTML encoded value containing the other scopes this scope depends on + * @param \Backend\Classes\FilterScope $scope + * @return string + */ + protected function getScopeDepends($scope) + { + if (!$scope->dependsOn) { + return ''; + } + + $dependsOn = is_array($scope->dependsOn) ? $scope->dependsOn : [$scope->dependsOn]; + $dependsOn = htmlspecialchars(json_encode($dependsOn), ENT_QUOTES, 'UTF-8'); + return $dependsOn; + } + // // AJAX // @@ -295,7 +311,14 @@ class Filter extends WidgetBase $scope = $this->getScope($scopeName); $activeKeys = $scope->value ? array_keys($scope->value) : []; $available = $this->getAvailableOptions($scope, $searchQuery); - $active = $searchQuery ? [] : $this->filterActiveOptions($activeKeys, $available); + + if ($searchQuery) { + $active = []; + } else { + // Ensure that only valid values are set on the current scope + $active = $this->filterActiveOptions($activeKeys, $available); + $this->setScopeValue($scope, $active); + } return [ 'scopeName' => $scopeName, @@ -426,7 +449,11 @@ class Filter extends WidgetBase ])); } - $options = $model->$methodName(); + if (!empty($scope->dependsOn)) { + $options = $model->$methodName($this->getScopes()); + } else { + $options = $model->$methodName(); + } } elseif (!is_array($options)) { $options = []; @@ -663,6 +690,15 @@ class Filter extends WidgetBase $this->defineFilterScopes(); foreach ($this->allScopes as $scope) { + // Ensure that only valid values are set scopes of type: group + if ($scope->type === 'group') { + $activeKeys = $scope->value ? array_keys($scope->value) : []; + $available = $this->getAvailableOptions($scope); + $active = $this->filterActiveOptions($activeKeys, $available); + $value = !empty($active) ? $active : null; + $this->setScopeValue($scope, $value); + } + $this->applyScopeToQuery($scope, $query); } @@ -805,6 +841,10 @@ class Filter extends WidgetBase default: $value = is_array($scope->value) ? array_keys($scope->value) : $scope->value; + if (empty($value)) { + break; + } + /* * Condition */ diff --git a/modules/backend/widgets/filter/partials/_scope_group.htm b/modules/backend/widgets/filter/partials/_scope_group.htm index e5628d359..c5cc726eb 100644 --- a/modules/backend/widgets/filter/partials/_scope_group.htm +++ b/modules/backend/widgets/filter/partials/_scope_group.htm @@ -2,7 +2,9 @@ + data-scope-name="scopeName ?>" + getScopeDepends($scope)): ?>data-scope-depends="" +> label)) ?>: value ? count($scope->value) : e(trans('backend::lang.filter.all')) ?> diff --git a/modules/system/assets/ui/js/filter.js b/modules/system/assets/ui/js/filter.js index b6251fbef..543575e9a 100644 --- a/modules/system/assets/ui/js/filter.js +++ b/modules/system/assets/ui/js/filter.js @@ -19,7 +19,6 @@ +function ($) { "use strict"; var FilterWidget = function (element, options) { - this.$el = $(element); this.options = options || {} @@ -28,6 +27,12 @@ this.activeScopeName = null this.isActiveScopeDirty = false + /* + * Throttle dependency updating + */ + this.dependantUpdateInterval = 300 + this.dependantUpdateTimers = {} + this.init() } @@ -89,6 +94,9 @@ FilterWidget.prototype.init = function() { var self = this + this.bindDependants() + + // Setup event handler on type: checkbox scopes this.$el.on('change', '.filter-scope input[type="checkbox"]', function(){ var $scope = $(this).closest('.filter-scope') @@ -100,12 +108,14 @@ } }) + // Apply classes to type: checkbox scopes that are active from the server $('.filter-scope input[type="checkbox"]', this.$el).each(function() { $(this) .closest('.filter-scope') .toggleClass('active', $(this).is(':checked')) }) + // Setup click handler on type: group scopes this.$el.on('click', 'a.filter-scope', function(){ var $scope = $(this), scopeName = $scope.data('scope-name') @@ -120,6 +130,7 @@ $scope.addClass('filter-scope-open') }) + // Setup event handlers on type: group scopes' controls this.$el.on('show.oc.popover', 'a.filter-scope', function(event){ self.focusSearch() @@ -144,9 +155,9 @@ e.preventDefault() self.filterScope(true) }) - }) + // Setup event handler to apply selected options when closing the type: group scope popup this.$el.on('hide.oc.popover', 'a.filter-scope', function(){ var $scope = $(this) self.pushOptions(self.activeScopeName) @@ -158,6 +169,81 @@ }) } + /* + * Bind dependant scopes + */ + FilterWidget.prototype.bindDependants = function() { + if (!$('[data-scope-depends]', this.$el).length) { + return; + } + + var self = this, + scopeMap = {}, + scopeElements = this.$el.find('.filter-scope') + + /* + * Map master and slave scope + */ + scopeElements.filter('[data-scope-depends]').each(function() { + var name = $(this).data('scope-name'), + depends = $(this).data('scope-depends') + + $.each(depends, function(index, depend){ + if (!scopeMap[depend]) { + scopeMap[depend] = { scopes: [] } + } + + scopeMap[depend].scopes.push(name) + }) + }) + + /* + * When a master is updated, refresh its slaves + */ + $.each(scopeMap, function(scopeName, toRefresh){ + scopeElements.filter('[data-scope-name="'+scopeName+'"]') + .on('change.oc.filterScope', $.proxy(self.onRefreshDependants, self, scopeName, toRefresh)) + }) + } + + /* + * Refresh a dependancy scope + * Uses a throttle to prevent duplicate calls and click spamming + */ + FilterWidget.prototype.onRefreshDependants = function(scopeName, toRefresh) { + var self = this, + scopeElements = this.$el.find('.filter-scope') + + if (this.dependantUpdateTimers[scopeName] !== undefined) { + window.clearTimeout(this.dependantUpdateTimers[scopeName]) + } + + this.dependantUpdateTimers[scopeName] = window.setTimeout(function() { + $.each(toRefresh.scopes, function (index, dependantScope) { + self.scopeValues[dependantScope] = null + var $scope = self.$el.find('[data-scope-name="'+dependantScope+'"]') + + /* + * Request options from server + */ + self.$el.request(self.options.optionsHandler, { + data: { scopeName: dependantScope }, + success: function(data) { + self.fillOptions(dependantScope, data.options) + self.updateScopeSetting($scope, data.options.active.length) + $scope.loadIndicator('hide') + } + }) + }) + }, this.dependantUpdateInterval) + + $.each(toRefresh.scopes, function(index, scope) { + scopeElements.filter('[data-scope-name="'+scope+'"]') + .addClass('loading-indicator-container') + .loadIndicator() + }) + } + FilterWidget.prototype.focusSearch = function() { if (Modernizr.touchevents) return @@ -369,7 +455,7 @@ var items = $('#controlFilterPopover .filter-active-items > ul'), buttonContainer = $('#controlFilterPopover .filter-buttons') - if(data) { + if (data) { data.active.length > 0 ? buttonContainer.show() : buttonContainer.hide() } else { items.children().length > 0 ? buttonContainer.show() : buttonContainer.hide() @@ -383,16 +469,21 @@ if (!this.isActiveScopeDirty || !this.options.updateHandler) return - var data = { + var self = this, + data = { scopeName: scopeName, options: this.scopeValues[scopeName] } $.oc.stripeLoadIndicator.show() + this.$el.request(this.options.updateHandler, { data: data - }).always(function(){ + }).always(function () { $.oc.stripeLoadIndicator.hide() + }).done(function () { + // Trigger dependsOn updates on successful requests + self.$el.find('[data-scope-name="'+scopeName+'"]').trigger('change.oc.filterScope') }) } diff --git a/modules/system/assets/ui/less/filter.less b/modules/system/assets/ui/less/filter.less index 071ed0658..3d591af83 100644 --- a/modules/system/assets/ui/less/filter.less +++ b/modules/system/assets/ui/less/filter.less @@ -55,6 +55,27 @@ .transition(color 0.6s); } + &.loading-indicator-container.in-progress { + pointer-events: none; + cursor: default; + + .loading-indicator { + background: transparent; + + > span { + left: unset; + right: 0; + top: 10px; + background-color: @color-filter-bg; + border-radius: 50%; + margin-top: 0; + width: 20px; + height: 20px; + background-size: 15px 15px; + } + } + } + &:after { font-size: 14px; .icon(@angle-down); diff --git a/modules/system/assets/ui/storm-min.js b/modules/system/assets/ui/storm-min.js index d556981b3..f3fe8efe3 100644 --- a/modules/system/assets/ui/storm-min.js +++ b/modules/system/assets/ui/storm-min.js @@ -3046,6 +3046,8 @@ this.scopeValues={} this.$activeScope=null this.activeScopeName=null this.isActiveScopeDirty=false +this.dependantUpdateInterval=300 +this.dependantUpdateTimers={} this.init()} FilterWidget.DEFAULTS={optionsHandler:null,updateHandler:null} FilterWidget.prototype.getPopoverTemplate=function(){return' \ @@ -3093,6 +3095,7 @@ FilterWidget.prototype.getPopoverTemplate=function(){return' \ '} FilterWidget.prototype.init=function(){var self=this +this.bindDependants() this.$el.on('change','.filter-scope input[type="checkbox"]',function(){var $scope=$(this).closest('.filter-scope') if($scope.hasClass('is-indeterminate')){self.switchToggle($(this))} else{self.checkboxToggle($(this))}}) @@ -3117,6 +3120,20 @@ self.pushOptions(self.activeScopeName) self.activeScopeName=null self.$activeScope=null setTimeout(function(){$scope.removeClass('filter-scope-open')},200)})} +FilterWidget.prototype.bindDependants=function(){if(!$('[data-scope-depends]',this.$el).length){return;} +var self=this,scopeMap={},scopeElements=this.$el.find('.filter-scope') +scopeElements.filter('[data-scope-depends]').each(function(){var name=$(this).data('scope-name'),depends=$(this).data('scope-depends') +$.each(depends,function(index,depend){if(!scopeMap[depend]){scopeMap[depend]={scopes:[]}} +scopeMap[depend].scopes.push(name)})}) +$.each(scopeMap,function(scopeName,toRefresh){scopeElements.filter('[data-scope-name="'+scopeName+'"]').on('change.oc.filterScope',$.proxy(self.onRefreshDependants,self,scopeName,toRefresh))})} +FilterWidget.prototype.onRefreshDependants=function(scopeName,toRefresh){var self=this,scopeElements=this.$el.find('.filter-scope') +if(this.dependantUpdateTimers[scopeName]!==undefined){window.clearTimeout(this.dependantUpdateTimers[scopeName])} +this.dependantUpdateTimers[scopeName]=window.setTimeout(function(){$.each(toRefresh.scopes,function(index,dependantScope){self.scopeValues[dependantScope]=null +var $scope=self.$el.find('[data-scope-name="'+dependantScope+'"]') +self.$el.request(self.options.optionsHandler,{data:{scopeName:dependantScope},success:function(data){self.fillOptions(dependantScope,data.options) +self.updateScopeSetting($scope,data.options.active.length) +$scope.loadIndicator('hide')}})})},this.dependantUpdateInterval) +$.each(toRefresh.scopes,function(index,scope){scopeElements.filter('[data-scope-name="'+scope+'"]').addClass('loading-indicator-container').loadIndicator()})} FilterWidget.prototype.focusSearch=function(){if(Modernizr.touchevents) return var $input=$('#controlFilterPopover input.filter-search-input'),length=$input.val().length @@ -3191,9 +3208,9 @@ FilterWidget.prototype.toggleFilterButtons=function(data) if(data){data.active.length>0?buttonContainer.show():buttonContainer.hide()}else{items.children().length>0?buttonContainer.show():buttonContainer.hide()}} FilterWidget.prototype.pushOptions=function(scopeName){if(!this.isActiveScopeDirty||!this.options.updateHandler) return -var data={scopeName:scopeName,options:this.scopeValues[scopeName]} +var self=this,data={scopeName:scopeName,options:this.scopeValues[scopeName]} $.oc.stripeLoadIndicator.show() -this.$el.request(this.options.updateHandler,{data:data}).always(function(){$.oc.stripeLoadIndicator.hide()})} +this.$el.request(this.options.updateHandler,{data:data}).always(function(){$.oc.stripeLoadIndicator.hide()}).done(function(){self.$el.find('[data-scope-name="'+scopeName+'"]').trigger('change.oc.filterScope')})} FilterWidget.prototype.checkboxToggle=function($el){var isChecked=$el.is(':checked'),$scope=$el.closest('.filter-scope'),scopeName=$scope.data('scope-name') this.scopeValues[scopeName]=isChecked if(this.options.updateHandler){var data={scopeName:scopeName,value:isChecked} @@ -3902,7 +3919,7 @@ $.oc.chartUtils=new ChartUtils();}(window.jQuery);+function($){"use strict";var this.chartOptions={xaxis:{mode:"time",tickLength:5},selection:{mode:"x"},grid:{markingsColor:"rgba(0,0,0, 0.02)",backgroundColor:{colors:["#fff","#fff"]},borderColor:"#7bafcc",borderWidth:0,color:"#ddd",hoverable:true,clickable:true,labelMargin:10},series:{lines:{show:true,fill:true},points:{show:true}},tooltip:true,tooltipOpts:{defaultTheme:false,content:"%x: %y",dateFormat:"%y-%0m-%0d",shifts:{x:10,y:20}},legend:{show:true,noColumns:2}} this.defaultDataSetOptions={shadowSize:0} var parsedOptions={} -try{parsedOptions=ocJSON("{"+value+"}");}catch(e){throw new Error('Error parsing the data-chart-options attribute value. '+e);} +try{parsedOptions=ocJSON("{"+options.chartOptions+"}");}catch(e){throw new Error('Error parsing the data-chart-options attribute value. '+e);} this.chartOptions=$.extend({},this.chartOptions,parsedOptions) this.options=options this.$el=$(element) diff --git a/modules/system/assets/ui/storm.css b/modules/system/assets/ui/storm.css index e37b2c053..78c309784 100644 --- a/modules/system/assets/ui/storm.css +++ b/modules/system/assets/ui/storm.css @@ -4786,6 +4786,9 @@ ul.autocomplete.dropdown-menu.inspector-autocomplete li a {padding:5px 12px;whit .control-filter >.filter-scope {display:inline-block;padding:10px} .control-filter >.filter-scope .filter-label {margin-right:5px} .control-filter >.filter-scope .filter-setting {display:inline-block;margin-right:5px;-webkit-transition:color 0.6s;transition:color 0.6s} +.control-filter >.filter-scope.loading-indicator-container.in-progress {pointer-events:none;cursor:default} +.control-filter >.filter-scope.loading-indicator-container.in-progress .loading-indicator {background:transparent} +.control-filter >.filter-scope.loading-indicator-container.in-progress .loading-indicator >span {left:unset;right:0;top:10px;background-color:#ecf0f1;border-radius:50%;margin-top:0;width:20px;height:20px;background-size:15px 15px} .control-filter >.filter-scope:after {font-size:14px;font-family:FontAwesome;font-weight:normal;font-style:normal;text-decoration:inherit;-webkit-font-smoothing:antialiased;content:"\f107"} .control-filter >.filter-scope.active .filter-setting {padding-left:5px;padding-right:5px;color:#FFF;background-color:#6aab55;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-transition:color 1s,background-color 1s;transition:color 1s,background-color 1s} .control-filter >.filter-scope.checkbox {padding-left:35px}