Merge pull request #1651 from gabsource/feature/list-scope-filter-by-dates

Feature to allow filtering lists by date scopes (single date or range)
This commit is contained in:
Samuel Georges 2016-05-12 04:46:06 +10:00
commit cdd280b6e1
11 changed files with 590 additions and 30 deletions

View File

@ -349,7 +349,11 @@ return [
],
'filter' => [
'all' => 'all',
'options_method_not_exists' => "The model class :model must define a method :method() returning options for the ':filter' filter."
'options_method_not_exists' => "The model class :model must define a method :method() returning options for the ':filter' filter.",
'date' => [
'all' => 'all period',
'format' =>'Y-m-d'
]
],
'import_export' => [
'upload_csv_file' => '1. Upload a CSV file',

View File

@ -350,7 +350,11 @@ return [
],
'filter' => [
'all' => 'tous',
'options_method_not_exists' => "La classe du modèle :model doit définir une méthode :method() qui retourne les options pour le filtre ':filter'."
'options_method_not_exists' => "La classe du modèle :model doit définir une méthode :method() qui retourne les options pour le filtre ':filter'.",
'date' => [
'all' => 'toute la période',
'format' =>'d/m/Y'
]
],
'import_export' => [
'upload_csv_file' => '1. Envoyer un fichier CSV',

View File

@ -1,5 +1,8 @@
<?php namespace Backend\Widgets;
use Backend\Classes\FormField;
use Backend\FormWidgets\DatePicker;
use Carbon\Carbon;
use Db;
use Str;
use Lang;
@ -97,6 +100,14 @@ class Filter extends WidgetBase
*/
public function renderScopeElement($scope)
{
switch ($scope->type) {
case 'datepicker':
case 'daterangepicker':
// Load datepicker assets
new DatePicker($this->controller, new FormField('dummy', 'dummy'));
break;
}
return $this->makePartial('scope_'.$scope->type, ['scope' => $scope]);
}
@ -133,6 +144,32 @@ class Filter extends WidgetBase
$value = post('value');
$this->setScopeValue($scope, $value);
break;
case 'datepicker':
$dates = $this->datesFromAjax(post('options.dates'));
if (!empty($dates)) {
list($date) = $dates;
} else {
$date = null;
}
$this->setScopeValue($scope, $date);
break;
case 'daterangepicker':
$dates = $this->datesFromAjax(post('options.dates'));
if (!empty($dates)) {
list($after, $before) = $dates;
$dates = [$after, $before];
} else {
$dates = null;
}
$this->setScopeValue($scope, $dates);
break;
}
/*
@ -392,6 +429,14 @@ class Filter extends WidgetBase
$this->scopeModels[$name] = $model;
}
/*
* Ensure dates options are set
*/
if (!isset($config['minDate'])) {
$scopeObj->minDate = '2000-01-01';
$scopeObj->maxDate = '2099-12-31';
}
$this->allScopes[$name] = $scopeObj;
}
}
@ -451,38 +496,60 @@ class Filter extends WidgetBase
return;
}
$value = is_array($scope->value) ? array_keys($scope->value) : $scope->value;
switch ($scope->type) {
case 'datepicker':
if ($scope->value instanceof Carbon && $scopeConditions = $scope->conditions) {
$query->whereRaw(strtr($scopeConditions, [':filtered' => $scope->value->format('Y-m-d')]));
}
/*
* Condition
*/
if ($scopeConditions = $scope->conditions) {
break;
case 'daterangepicker':
if (is_array($scope->value) && count($scope->value) > 1 && ($scopeConditions = $scope->conditions)) {
list($after, $before) = array_values($scope->value);
/*
* Switch scope: multiple conditions, value either 1 or 2
*/
if (is_array($scopeConditions)) {
$conditionNum = is_array($value) ? 0 : $value - 1;
list($scopeConditions) = array_slice($scopeConditions, $conditionNum);
}
if($after instanceof Carbon && $before instanceof Carbon) {
$query->whereRaw(strtr($scopeConditions, [
':after' => $after->format('Y-m-d'),
':before' => $before->format('Y-m-d')
]));
}
}
if (is_array($value)) {
$filtered = implode(',', array_build($value, function ($key, $_value) {
return [$key, Db::getPdo()->quote($_value)];
}));
}
else {
$filtered = Db::getPdo()->quote($value);
}
break;
default:
$value = is_array($scope->value) ? array_keys($scope->value) : $scope->value;
$query->whereRaw(DbDongle::parse(strtr($scopeConditions, [':filtered' => $filtered])));
}
/*
* Condition
*/
if ($scopeConditions = $scope->conditions) {
/*
* Scope
*/
if ($scopeMethod = $scope->scope) {
$query->$scopeMethod($value);
/*
* Switch scope: multiple conditions, value either 1 or 2
*/
if (is_array($scopeConditions)) {
$conditionNum = is_array($value) ? 0 : $value - 1;
list($scopeConditions) = array_slice($scopeConditions, $conditionNum);
}
if (is_array($value)) {
$filtered = implode(',', array_build($value, function ($key, $_value) {
return [$key, Db::getPdo()->quote($_value)];
}));
}
else {
$filtered = Db::getPdo()->quote($value);
}
$query->whereRaw(DbDongle::parse(strtr($scopeConditions, [':filtered' => $filtered])));
}
/*
* Scope
*/
if ($scopeMethod = $scope->scope) {
$query->$scopeMethod($value);
}
}
return $query;
@ -604,4 +671,45 @@ class Filter extends WidgetBase
}
return $processed;
}
/**
* Convert an array from the posted dates
*
* @param mixed $scope
* @param array $dates
*
* @return array
*/
protected function datesFromAjax($ajaxDates)
{
$dates = [];
if (null !== $ajaxDates) {
if (!is_array($ajaxDates)) {
$dates = [$ajaxDates];
}
foreach ($ajaxDates as $date) {
$dates[] = Carbon::createFromFormat('Y-m-d', $date);
}
}
return $dates;
}
/**
* @param mixed $scope
*
* @return string
*/
protected function getFilterDateFormat($scope)
{
if (isset($scope->date_format)) {
return $scope->date_format;
}
return trans('backend::lang.filter.date.format');
}
}

View File

@ -0,0 +1,14 @@
<!-- Group scope -->
<a
class="filter-scope-date <?= $scope->value ? 'active' : '' ?>"
href="javascript:;"
data-scope-name="<?= $scope->scopeName ?>"
data-scope-data="<?= e(json_encode( [
'date' => $scope->value && $scope->value instanceof \Carbon\Carbon ? $scope->value->format('Y-m-d') : null,
'minDate' => $scope->minDate ? $scope->minDate : '2000-01-01' ,
'maxDate' => $scope->maxDate ? $scope->maxDate : '2099-12-31',
]))
?>">
<span class="filter-label"><?= e(trans($scope->label)) ?>:</span>
<span class="filter-setting"><?= $scope->value && $scope->value instanceof \Carbon\Carbon ? $scope->value->format(trans('backend::lang.filter.date.format')) : e(trans('backend::lang.filter.date.all')) ?></span>
</a>

View File

@ -0,0 +1,14 @@
<!-- Group scope -->
<a
class="filter-scope-date range <?= $scope->value ? 'active' : '' ?>"
href="javascript:;"
data-scope-name="<?= $scope->scopeName ?>"
data-scope-data="<?= e(json_encode( [
'dates' => $scope->value && is_array($scope->value) ? array_map(function($value) { return $value instanceof \Carbon\Carbon ? $value->format('Y-m-d') : null; }, $scope->value) : null,
'minDate' => $scope->minDate ? $scope->minDate : '2000-01-01' ,
'maxDate' => $scope->maxDate ? $scope->maxDate : '2099-12-31',
]))
?>">
<span class="filter-label"><?= e(trans($scope->label)) ?>:</span>
<span class="filter-setting"><?= $scope->value && is_array($scope->value) ? join(' → ', array_map(function($value) { return $value instanceof \Carbon\Carbon ? $value->format(trans('backend::lang.filter.date.format')) : null; }, $scope->value)) : e(trans('backend::lang.filter.date.all')) ?></span>
</a>

View File

@ -0,0 +1,344 @@
/*
* Filter Widget
*
* Data attributes:
* - data-behavior="filter" - enables the filter plugin
*
* Dependences:
* - October Popover (october.popover.js)
*
* Notes:
* Ideally this control would not depend on loader or the AJAX framework,
* then the Filter widget can use events to handle this business logic.
*
* Require:
* - mustache/mustache
* - modernizr/modernizr
* - storm/popover
*/
+function ($) {
"use strict";
var FilterWidget = $.fn.filterWidget.Constructor;
/* ------------------------------------------------------------------------
* Overloaded module functions
*/
var overloaded_init = FilterWidget.prototype.init;
FilterWidget.prototype.init = function () {
overloaded_init.apply(this);
this.initFilterDate();
};
/*------------------------------------------------------------------------
* New module functions
*/
FilterWidget.prototype.initFilterDate = function () {
var self = this;
this.$el.on('show.oc.popover', 'a.filter-scope-date', function () {
self.initDatePickers($(this).hasClass('range'));
});
this.$el.on('hiding.oc.popover', 'a.filter-scope-date', function () {
self.clearDatePickers();
});
this.$el.on('hide.oc.popover', 'a.filter-scope-date', function () {
var $scope = $(this);
self.pushOptions(self.activeScopeName);
self.activeScopeName = null;
self.$activeScope = null;
// Second click closes the filter scope
setTimeout(function () {
$scope.removeClass('filter-scope-open')
}, 200)
});
this.$el.on('click', 'a.filter-scope-date', function () {
var $scope = $(this),
scopeName = $scope.data('scope-name');
// Ignore if already opened
if ($scope.hasClass('filter-scope-open')) return;
// Ignore if another popover is opened
if (null !== self.activeScopeName) return;
self.$activeScope = $scope;
self.activeScopeName = scopeName;
self.isActiveScopeDirty = false;
if ($scope.hasClass('range')) {
self.displayPopoverRange($scope)
} else {
self.displayPopoverDate($scope)
}
$scope.addClass('filter-scope-open')
});
$(document).on('click', '#controlFilterPopover [data-trigger="filter"]', function (e) {
e.preventDefault();
e.stopPropagation();
self.filterByDate()
});
$(document).on('click', '#controlFilterPopover [data-trigger="clear"]', function (e) {
e.preventDefault();
e.stopPropagation();
self.filterByDate(true)
})
};
/*
* Get popover date template
*/
FilterWidget.prototype.getPopoverDateTemplate = function () {
return ' \
<form> \
<input type="hidden" name="scopeName" value="{{ scopeName }}" /> \
<div id="controlFilterPopover" class="control-filter-popover control-filter-date-popover"> \
<div class="filter-search loading-indicator-container size-input-text"> \
<div class="field-datepicker"> \
<div class="input-with-icon right-align"> \
<i class="icon icon-calendar-o"></i> \
<input \
type="text" \
name="date" \
value="{{ date }}" \
class="form-control align-right" \
autocomplete="off" \
placeholder="{{ date_placeholder }}" /> \
</div> \
</div> \
<div class="filter-buttons"> \
<button class="btn btn-block btn-default" data-trigger="clear"> \
{{ reset_button_text }} \
</button> \
</div> \
</div> \
</div> \
</form> \
'
};
/*
* Get popover range template
*/
FilterWidget.prototype.getPopoverRangeTemplate = function () {
return ' \
<form> \
<input type="hidden" name="scopeName" value="{{ scopeName }}" /> \
<div id="controlFilterPopover" class="control-filter-popover control-filter-date-popover"> \
<div class="filter-search loading-indicator-container size-input-text"> \
<div class="field-datepicker"> \
<div class="input-with-icon right-align"> \
<i class="icon icon-calendar-o"></i> \
<input \
type="text" \
name="date" \
value="{{ date }}" \
class="form-control align-right" \
autocomplete="off" \
placeholder="{{ after_placeholder }}" /> \
</div> \
</div> \
<div class="field-datepicker"> \
<div class="input-with-icon right-align"> \
<i class="icon icon-calendar-o"></i> \
<input \
type="text" \
name="date" \
value="{{ date }}" \
class="form-control align-right" \
autocomplete="off" \
placeholder="{{ before_placeholder }}" /> \
</div> \
</div> \
<div class="filter-buttons"> \
<button class="btn btn-block btn-primary oc-icon-search" data-trigger="filter"> \
{{ filter_button_text }} \
</button> \
<button class="btn btn-block btn-default" data-trigger="clear"> \
{{ reset_button_text }} \
</button> \
</div> \
</div> \
</div> \
</form> \
'
};
FilterWidget.prototype.displayPopoverDate = function ($scope) {
var self = this,
scopeName = $scope.data('scope-name'),
data = this.scopeValues[scopeName];
data = $.extend({}, data, {
filter_button_text: $.oc.lang.get('filter.dates.filter_button_text'),
reset_button_text: $.oc.lang.get('filter.dates.reset_button_text'),
date_placeholder: $.oc.lang.get('filter.dates.date_placeholder')
});
data.scopeName = scopeName;
// Destroy any popovers already bound
$scope.data('oc.popover', null);
$scope.ocPopover({
content: Mustache.render(self.getPopoverDateTemplate(), data),
modal: false,
highlightModalTarget: true,
closeOnPageClick: true,
placement: 'bottom',
onCheckDocumentClickTarget: function (target) {
return self.onCheckDocumentClickTargetDatePicker(target);
}
})
};
FilterWidget.prototype.displayPopoverRange = function ($scope) {
var self = this,
scopeName = $scope.data('scope-name'),
data = this.scopeValues[scopeName];
data = $.extend({}, data, {
filter_button_text: $.oc.lang.get('filter.dates.filter_button_text'),
reset_button_text: $.oc.lang.get('filter.dates.reset_button_text'),
after_placeholder: $.oc.lang.get('filter.dates.after_placeholder'),
before_placeholder: $.oc.lang.get('filter.dates.before_placeholder')
});
data.scopeName = scopeName;
// Destroy any popovers already bound
$scope.data('oc.popover', null);
$scope.ocPopover({
content: Mustache.render(self.getPopoverRangeTemplate(), data),
modal: false,
highlightModalTarget: true,
closeOnPageClick: true,
placement: 'bottom',
onCheckDocumentClickTarget: function (target) {
return self.onCheckDocumentClickTargetDatePicker(target);
}
})
};
FilterWidget.prototype.initDatePickers = function (isRange) {
var self = this,
scopeData = self.$activeScope.data('scope-data'),
$inputs = $('.field-datepicker input', '#controlFilterPopover'),
data = self.scopeValues[self.activeScopeName];
if (!data) {
data = {
dates: isRange ? (scopeData.dates ? scopeData.dates : []) : (scopeData.date ? [scopeData.date] : [])
}
}
$inputs.each(function (index, datepicker) {
var defaultValue = '',
$datepicker = $(datepicker),
defaults = {
minDate: new Date(scopeData.minDate),
maxDate: new Date(scopeData.maxDate),
yearRange: 10,
setDefaultDate: '' !== defaultValue ? defaultValue.toDate() : '',
format: self.getDateFormat(),
i18n: $.oc.lang.get('datepicker')
};
if (0 <= index && index < data.dates.length) {
defaultValue = moment(data.dates[index], 'YYYY-MM-DD')
}
if (!isRange) {
defaults.onSelect = function () {
self.filterByDate()
}
}
datepicker.value = '' !== defaultValue ? defaultValue.format(self.getDateFormat()) : '';
$datepicker.pikaday(defaults)
})
};
FilterWidget.prototype.clearDatePickers = function () {
var $inputs = $('.field-datepicker input', '#controlFilterPopover');
$inputs.each(function (index, datepicker) {
var $datepicker = $(datepicker);
$datepicker.data('pikaday').destroy();
})
};
FilterWidget.prototype.updateScopeDateSetting = function ($scope, dates) {
var self = this,
$setting = $scope.find('.filter-setting'),
dateFormat = self.getDateFormat();
if (dates && dates.length) {
if (dates.length > 1) {
var after = moment(dates[0], 'YYYY-MM-DD').format(dateFormat),
before = moment(dates[1], 'YYYY-MM-DD').format(dateFormat);
$setting.text(after + ' → ' + before)
} else {
$setting.text(moment(dates[0], 'YYYY-MM-DD').format(dateFormat))
}
$scope.addClass('active')
}
else {
$setting.text($.oc.lang.get('filter.dates.all'));
$scope.removeClass('active')
}
};
FilterWidget.prototype.filterByDate = function (isReset) {
var self = this,
dates = [];
if (!isReset) {
$('.field-datepicker input', '#controlFilterPopover').each(function (index, datepicker) {
dates.push($(datepicker).data('pikaday').toString('YYYY-MM-DD'))
})
}
self.updateScopeDateSetting(self.$activeScope, dates);
self.scopeValues[self.activeScopeName] = {
dates: dates
};
self.isActiveScopeDirty = true;
self.$activeScope.data('oc.popover').hide()
};
FilterWidget.prototype.getDateFormat = function () {
return $.oc.lang.get('filter.dates.format')
};
FilterWidget.prototype.onCheckDocumentClickTargetDatePicker = function (target) {
var $target = $(target);
// If the click happens on a pikaday element, do not close the popover
return $target.hasClass('pika-next') ||
$target.hasClass('pika-prev') ||
$target.hasClass('pika-select') ||
$target.hasClass('pika-button') ||
$target.parents('.pika-table').length ||
$target.parents('.pika-title').length;
};
}(window.jQuery);

View File

@ -158,7 +158,7 @@
$scope.addClass('active')
}
else {
$setting.text('all')
$setting.text($.oc.lang.get('filter.group.all'))
$scope.removeClass('active')
}
}

View File

@ -100,6 +100,38 @@
}
}
}
> .filter-scope-date {
display: inline-block;
padding: 15px;
.filter-label {}
.filter-setting {
display: inline-block;
.transition(color 0.6s);
}
&:after {
font-size: 14px;
.icon(@angle-down);
}
&.active {
.filter-setting {
padding-left: 5px;
padding-right: 5px;
color: #FFF;
background-color: @color-filter-bg-active;
.border-radius(4px);
.transition(~'color 1s, background-color 1s');
}
}
&:hover {
color: #000;
.filter-label { color: @color-filter-text; }
&.active .filter-setting { background-color: darken(@color-filter-bg-active, 5%); }
}
}
}
.control-filter-popover {
@ -183,6 +215,16 @@
a:before { .icon(@times); }
li.animate-enter { .animation(fadeInDown .5s); }
}
&.control-filter-date-popover {
min-width: 190px;
.filter-buttons .btn {
border-radius: 0;
text-align: center;
margin: 0;
}
}
}
@media (max-width: @screen-xs) {

View File

@ -31,6 +31,7 @@
=require js/tooltip.js
=require js/toolbar.js
=require js/filter.js
=require js/filter.dates.js
=require js/select.js
=require js/loader.base.js
=require js/loader.cursor.js

View File

@ -59,6 +59,20 @@ return [
'weekdaysShort' => ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
],
'filter' => [
'group' => [
'all' => 'all'
],
'dates' => [
'format' => 'YYYY-MM-DD', // Moment.js date format
'all' => 'all period',
'filter_button_text' => 'Filter',
'reset_button_text' => 'Reset',
'date_placeholder' => 'Date',
'after_placeholder' => 'After',
'before_placeholder' => 'Before'
]
],
];

View File

@ -61,6 +61,21 @@ return [
'weekdays' => ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'],
'weekdaysShort' => ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']
],
'filter' => [
'group' => [
'all' => 'tous'
],
'dates' => [
'format' => 'DD/MM/YYYY', // Moment.js date format
'all' => 'toute la période',
'filter_button_text' => 'Filtrer',
'reset_button_text' => 'Effacer',
'date_placeholder' => 'Date',
'after_placeholder' => 'Après le',
'before_placeholder' => 'Avant le',
]
],
];