From 70107c63762e56b027a68daa3dd1a8b1d0e37fdd Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Tue, 12 Nov 2019 17:02:25 -0600 Subject: [PATCH 1/4] Initial WIP on implementing dependsOn support for filter scopes. Still need to resolve an issue where if the slave filter has values set when the master filter updates, thus triggering a change of the available options to the slave, the original values are still set on the slave but not actually visible in the popup as options because they're no longer valid options. To fix this we'll need the ability to get the browser to refresh the slave filter's selected values (count icon basically since it already forces the options popup to refresh) when its masters update; while at the same rechecking the slave's scope values set on the server to ensure that they're all valid and there aren't values left over from the previous request that are no longer valid but are still being applied to the query. --- modules/backend/classes/FilterScope.php | 54 ++++++------ modules/backend/widgets/Filter.php | 37 ++++++++- .../widgets/filter/partials/_scope_group.htm | 4 +- modules/system/assets/ui/js/filter.js | 82 +++++++++++++++++-- 4 files changed, 144 insertions(+), 33 deletions(-) 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..4902b849c 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, array_keys($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 = []; @@ -644,6 +671,12 @@ class Filter extends WidgetBase /* * Set scope value */ + if ($scope->type === 'group') { + + } + + + $scope->value = $this->getScopeValue($scope, @$config['default']); return $scope; 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..f3675beed 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,62 @@ }) } + /* + * 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 + }) + }, this.dependantUpdateInterval) + } + FilterWidget.prototype.focusSearch = function() { if (Modernizr.touchevents) return @@ -369,7 +436,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 +450,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') }) } From 6f0e4afbbddef9b8e73cfb8359a6205591d850eb Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Tue, 12 Nov 2019 17:06:23 -0600 Subject: [PATCH 2/4] cleanup --- modules/backend/widgets/Filter.php | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/modules/backend/widgets/Filter.php b/modules/backend/widgets/Filter.php index 4902b849c..05c9ce786 100644 --- a/modules/backend/widgets/Filter.php +++ b/modules/backend/widgets/Filter.php @@ -667,16 +667,6 @@ class Filter extends WidgetBase $scope = new FilterScope($name, $label); $scope->displayAs($scopeType, $config); $scope->idPrefix = $this->alias; - - /* - * Set scope value - */ - if ($scope->type === 'group') { - - } - - - $scope->value = $this->getScopeValue($scope, @$config['default']); return $scope; From 17b8ba75af23ecdca68ea2837784b5b37b203aab Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Tue, 12 Nov 2019 17:07:10 -0600 Subject: [PATCH 3/4] re-add accidentally removed comment --- modules/backend/widgets/Filter.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/modules/backend/widgets/Filter.php b/modules/backend/widgets/Filter.php index 05c9ce786..4a6e3e359 100644 --- a/modules/backend/widgets/Filter.php +++ b/modules/backend/widgets/Filter.php @@ -667,6 +667,10 @@ class Filter extends WidgetBase $scope = new FilterScope($name, $label); $scope->displayAs($scopeType, $config); $scope->idPrefix = $this->alias; + + /* + * Set scope value + */ $scope->value = $this->getScopeValue($scope, @$config['default']); return $scope; From 19ce51ba4a351b0dd74da1b3888f5b628e3aaf65 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Wed, 13 Nov 2019 13:55:06 -0600 Subject: [PATCH 4/4] Implemented client side refresh of dependent options & server side checking of valid filter values before applying them to the query --- modules/backend/widgets/Filter.php | 15 ++++++++++++++- modules/system/assets/ui/js/filter.js | 19 +++++++++++++++++++ modules/system/assets/ui/less/filter.less | 21 +++++++++++++++++++++ modules/system/assets/ui/storm-min.js | 23 ++++++++++++++++++++--- modules/system/assets/ui/storm.css | 3 +++ 5 files changed, 77 insertions(+), 4 deletions(-) diff --git a/modules/backend/widgets/Filter.php b/modules/backend/widgets/Filter.php index 4a6e3e359..738b81232 100644 --- a/modules/backend/widgets/Filter.php +++ b/modules/backend/widgets/Filter.php @@ -317,7 +317,7 @@ class Filter extends WidgetBase } else { // Ensure that only valid values are set on the current scope $active = $this->filterActiveOptions($activeKeys, $available); - $this->setScopeValue($scope, array_keys($active)); + $this->setScopeValue($scope, $active); } return [ @@ -690,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); } @@ -832,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/system/assets/ui/js/filter.js b/modules/system/assets/ui/js/filter.js index f3675beed..543575e9a 100644 --- a/modules/system/assets/ui/js/filter.js +++ b/modules/system/assets/ui/js/filter.js @@ -221,8 +221,27 @@ 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() { 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}