Merge pull request #4764 from octobercms/wip/fix-4254

Implemented dependsOn support for filter scopes. Fixes #4254. Documented by https://github.com/octobercms/docs/pull/419.
This commit is contained in:
Luke Towers 2019-11-15 15:05:29 -06:00 committed by GitHub
commit 46fe07c2da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 214 additions and 36 deletions

View File

@ -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;
}

View File

@ -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
*/

View File

@ -2,7 +2,9 @@
<a
class="filter-scope <?= $scope->value ? 'active' : '' ?>"
href="javascript:;"
data-scope-name="<?= $scope->scopeName ?>">
data-scope-name="<?= $scope->scopeName ?>"
<?php if ($depends = $this->getScopeDepends($scope)): ?>data-scope-depends="<?= $depends ?>"<?php endif ?>
>
<span class="filter-label"><?= e(trans($scope->label)) ?>:</span>
<span class="filter-setting"><?= $scope->value ? count($scope->value) : e(trans('backend::lang.filter.all')) ?></span>
</a>

View File

@ -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')
})
}

View File

@ -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);

View File

@ -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'
</form> \
'}
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: <strong>%y</strong>",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)

View File

@ -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}