From b08c215b726d5558cfb22ada7fb005739b901f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20GAULIN?= Date: Sat, 12 Dec 2015 00:38:49 +0400 Subject: [PATCH] Filter lists by dates with two new scope types (date and range) --- modules/backend/lang/en/lang.php | 6 +- modules/backend/lang/fr/lang.php | 6 +- modules/backend/widgets/Filter.php | 113 +++++- .../widgets/filter/partials/_scope_date.htm | 14 + .../widgets/filter/partials/_scope_range.htm | 14 + modules/system/assets/ui/js/filter.dates.js | 344 ++++++++++++++++++ modules/system/assets/ui/less/filter.less | 42 +++ modules/system/assets/ui/storm.js | 1 + modules/system/lang/en/client.php | 14 + modules/system/lang/fr/client.php | 15 + 10 files changed, 566 insertions(+), 3 deletions(-) create mode 100644 modules/backend/widgets/filter/partials/_scope_date.htm create mode 100644 modules/backend/widgets/filter/partials/_scope_range.htm create mode 100644 modules/system/assets/ui/js/filter.dates.js diff --git a/modules/backend/lang/en/lang.php b/modules/backend/lang/en/lang.php index c1215f83f..6ca4effa3 100644 --- a/modules/backend/lang/en/lang.php +++ b/modules/backend/lang/en/lang.php @@ -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', diff --git a/modules/backend/lang/fr/lang.php b/modules/backend/lang/fr/lang.php index 4fdaeb8a2..dba5d29ec 100644 --- a/modules/backend/lang/fr/lang.php +++ b/modules/backend/lang/fr/lang.php @@ -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', diff --git a/modules/backend/widgets/Filter.php b/modules/backend/widgets/Filter.php index 30d5dd53a..dc585c684 100644 --- a/modules/backend/widgets/Filter.php +++ b/modules/backend/widgets/Filter.php @@ -1,5 +1,8 @@ type) { + case 'date': + case 'range': + // 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 'date': + $dates = $this->datesFromAjax(post('options.dates')); + + if ( ! empty( $dates )) { + list( $date ) = $dates; + } else { + $date = null; + } + + $this->setScopeValue($scope, $date); + break; + + case 'range': + $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,7 +496,13 @@ class Filter extends WidgetBase return; } - $value = is_array($scope->value) ? array_keys($scope->value) : $scope->value; + switch ($scope->type) { + case'date': + case'range': + $this->applyDateScopeToQuery($scope, $query); + break; + default: + $value = is_array($scope->value) ? array_keys($scope->value) : $scope->value; /* * Condition @@ -488,6 +539,25 @@ class Filter extends WidgetBase return $query; } + + protected function applyDateScopeToQuery($scope, $query) + { + if ('range' === $scope->type) { + if (is_array($scope->value) && count($scope->value) > 1 && ( $scopeConditions = $scope->conditions )) { + list( $after, $before ) = array_values($scope->value); + + $query->whereRaw(strtr($scopeConditions, [ + ':after' => $after->format('Y-m-d'), + ':before' => $before->format('Y-m-d') + ])); + } + } else { + if ($scopeConditions = $scope->conditions) { + $query->whereRaw(strtr($scopeConditions, [':filtered' => $scope->value->format('Y-m-d')])); + } + } + } + // // Access layer // @@ -604,4 +674,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'); + } } diff --git a/modules/backend/widgets/filter/partials/_scope_date.htm b/modules/backend/widgets/filter/partials/_scope_date.htm new file mode 100644 index 000000000..73bb6770d --- /dev/null +++ b/modules/backend/widgets/filter/partials/_scope_date.htm @@ -0,0 +1,14 @@ + + + label)) ?>: + value ? $scope->value->format(trans('backend::lang.filter.date.format')) : e(trans('backend::lang.filter.date.all')) ?> + diff --git a/modules/backend/widgets/filter/partials/_scope_range.htm b/modules/backend/widgets/filter/partials/_scope_range.htm new file mode 100644 index 000000000..191c5948e --- /dev/null +++ b/modules/backend/widgets/filter/partials/_scope_range.htm @@ -0,0 +1,14 @@ + + + label)) ?>: + value && is_array($scope->value) ? join(' → ', array_map(function($value) { return $value->format(trans('backend::lang.filter.date.format')); }, $scope->value)) : e(trans('backend::lang.filter.date.all')) ?> + diff --git a/modules/system/assets/ui/js/filter.dates.js b/modules/system/assets/ui/js/filter.dates.js new file mode 100644 index 000000000..a9cbb1daa --- /dev/null +++ b/modules/system/assets/ui/js/filter.dates.js @@ -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 ' \ +
\ + \ +
\ + \ +
\ +
\ + ' + }; + + /* + * Get popover range template + */ + FilterWidget.prototype.getPopoverRangeTemplate = function () { + return ' \ +
\ + \ +
\ + \ +
\ +
\ + ' + }; + + 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('pikaday') + }; + + 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); \ No newline at end of file diff --git a/modules/system/assets/ui/less/filter.less b/modules/system/assets/ui/less/filter.less index 4f337c3bc..bb47d24bf 100644 --- a/modules/system/assets/ui/less/filter.less +++ b/modules/system/assets/ui/less/filter.less @@ -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) { diff --git a/modules/system/assets/ui/storm.js b/modules/system/assets/ui/storm.js index ad1dc8b12..31ae224c6 100644 --- a/modules/system/assets/ui/storm.js +++ b/modules/system/assets/ui/storm.js @@ -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 diff --git a/modules/system/lang/en/client.php b/modules/system/lang/en/client.php index 506f05c70..631c5b697 100644 --- a/modules/system/lang/en/client.php +++ b/modules/system/lang/en/client.php @@ -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' + ] + ], ]; diff --git a/modules/system/lang/fr/client.php b/modules/system/lang/fr/client.php index 8f096b371..dee648a45 100644 --- a/modules/system/lang/fr/client.php +++ b/modules/system/lang/fr/client.php @@ -61,6 +61,21 @@ return [ 'weekdays' => ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'], 'weekdaysShort' => ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam'] ], + + 'filter' => [ + 'group' => [ + 'all' => 'all' + ], + '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', + ] + ], ];