From f1849c2ea08b2838360d624559ac061b56d920d8 Mon Sep 17 00:00:00 2001 From: Sam Georges Date: Mon, 11 Aug 2014 21:46:29 +1000 Subject: [PATCH] Refs #5 - Progress to Filter widget --- modules/backend/assets/css/october.css | 39 ++- .../backend/assets/js/october.inspector.js | 23 +- modules/backend/assets/js/october.popover.js | 53 ++-- .../backend/assets/less/controls/filters.less | 32 ++- modules/backend/behaviors/ListController.php | 22 ++ modules/backend/classes/FilterScope.php | 118 +++++++++ modules/backend/widgets/Filter.php | 237 ++++++++++++++++++ .../filter/assets/js/october.filter.js | 167 ++++++++++++ .../widgets/filter/partials/_filter.htm | 18 +- .../filter/partials/_filter_scopes.htm | 3 + .../filter/partials/_scope_checkbox.htm | 7 + .../widgets/filter/partials/_scope_group.htm | 8 + 12 files changed, 674 insertions(+), 53 deletions(-) create mode 100644 modules/backend/classes/FilterScope.php create mode 100644 modules/backend/widgets/filter/assets/js/october.filter.js create mode 100644 modules/backend/widgets/filter/partials/_filter_scopes.htm create mode 100644 modules/backend/widgets/filter/partials/_scope_checkbox.htm create mode 100644 modules/backend/widgets/filter/partials/_scope_group.htm diff --git a/modules/backend/assets/css/october.css b/modules/backend/assets/css/october.css index 0efc517d9..e5a20c50b 100644 --- a/modules/backend/assets/css/october.css +++ b/modules/backend/assets/css/october.css @@ -9592,17 +9592,17 @@ table.table.data tr.list-tree-level-25 td.list-cell-index-1 { border-bottom: 1px solid #949ea6; } .control-filter a { - color: #949ea6; text-decoration: none; + color: #949ea6; } -.control-filter > .filter-set { +.control-filter > .filter-scope { display: inline-block; padding: 15px; } -.control-filter > .filter-set .filter-setting { +.control-filter > .filter-scope .filter-setting { display: inline-block; } -.control-filter > .filter-set:after { +.control-filter > .filter-scope:after { font-size: 14px; font-family: FontAwesome; font-weight: normal; @@ -9612,7 +9612,7 @@ table.table.data tr.list-tree-level-25 td.list-cell-index-1 { *margin-right: .3em; content: "\f107"; } -.control-filter > .filter-set.active .filter-setting { +.control-filter > .filter-scope.active .filter-setting { padding-left: 5px; padding-right: 5px; color: #FFF; @@ -9621,13 +9621,23 @@ table.table.data tr.list-tree-level-25 td.list-cell-index-1 { -moz-border-radius: 4px; border-radius: 4px; } -.control-filter > .filter-set:hover { +.control-filter > .filter-scope.checkbox { + padding-left: 35px; +} +.control-filter > .filter-scope.checkbox, +.control-filter > .filter-scope.checkbox label { + margin-bottom: 0; +} +.control-filter > .filter-scope.checkbox:after { + content: ''; +} +.control-filter > .filter-scope:hover { color: #000; } -.control-filter > .filter-set:hover .filter-label { +.control-filter > .filter-scope:hover .filter-label { color: #949ea6; } -.control-filter > .filter-set:hover.active .filter-setting { +.control-filter > .filter-scope:hover.active .filter-setting { background-color: #b32d00; } .control-filter-popover { @@ -9689,6 +9699,19 @@ table.table.data tr.list-tree-level-25 td.list-cell-index-1 { *margin-right: .3em; content: "\f067"; } +.control-filter-popover .filter-items li.loading { + padding: 7px; +} +.control-filter-popover .filter-items li.loading > span { + display: block; + height: 20px; + width: 20px; + background-image: url(../images/loading-indicator.svg); + background-size: 20px 20px; + background-position: 50% 50%; + -webkit-animation: spin 1s linear infinite; + animation: spin 1s linear infinite; +} .control-filter-popover .filter-active-items a:before { font-family: FontAwesome; font-weight: normal; diff --git a/modules/backend/assets/js/october.inspector.js b/modules/backend/assets/js/october.inspector.js index 227c96505..84d186dac 100644 --- a/modules/backend/assets/js/october.inspector.js +++ b/modules/backend/assets/js/october.inspector.js @@ -182,14 +182,14 @@ this.$el.data('oc.inspectorVisible', true) var displayPopover = function() { - var offset = self.$el.data('inspector-offset') + var offset = self.$el.data('inspector-offset') if (offset === undefined) offset = 15 - - var offsetX = self.$el.data('inspector-offset-x'), - offsetY = self.$el.data('inspector-offset-y') - var placement = self.$el.data('inspector-placement') + var offsetX = self.$el.data('inspector-offset-x'), + offsetY = self.$el.data('inspector-offset-y') + + var placement = self.$el.data('inspector-placement') if (placement === undefined) placement = 'bottom' @@ -248,8 +248,9 @@ displayPopover() } - // Creates group nodes in the property set - // + /* + * Creates group nodes in the property set + */ Inspector.prototype.preprocessConfig = function() { var fields = [], result = { @@ -707,15 +708,15 @@ InspectorEditorDropdown.prototype.showLoadingIndicator = function() { if (!Modernizr.touch) - this.indicatorContainer.loadIndicator({'opaque': true}) + this.indicatorContainer.loadIndicator({'opaque': true}) } InspectorEditorDropdown.prototype.hideLoadingIndicator = function() { if (!Modernizr.touch) - this.indicatorContainer.loadIndicator('hide') + this.indicatorContainer.loadIndicator('hide') } - InspectorEditorDropdown.prototype.loadOptions= function() { + InspectorEditorDropdown.prototype.loadOptions = function() { var $form = $(this.selector).closest('form'), data = this.inspector.propertyValues, $select = $(this.selector), @@ -764,7 +765,7 @@ // INSPECTOR DATA-API // ================== - + $(document).on('click', '[data-inspectable]', function(){ var $this = $(this), inspector = $this.data('oc.inspector') diff --git a/modules/backend/assets/js/october.popover.js b/modules/backend/assets/js/october.popover.js index d9e39bd5c..b12964100 100644 --- a/modules/backend/assets/js/october.popover.js +++ b/modules/backend/assets/js/october.popover.js @@ -55,14 +55,14 @@ Popover.prototype.hide = function() { var e = $.Event('hiding.oc.popover', {relatedTarget: this.$el}) this.$el.trigger(e, this) - if (e.isDefaultPrevented()) + if (e.isDefaultPrevented()) return if (this.$container) this.$container.remove() if (this.$overlay) this.$overlay.remove() - this.$overlay = false; - this.$container = false; + this.$overlay = false + this.$container = false this.$el.removeClass('popover-highlight') this.$el.data('oc.popover', null) @@ -70,46 +70,46 @@ $(document).unbind('mousedown', this.docClickHandler); this.$el.trigger('hide.oc.popover') - $(document).off('.oc.popover'); + $(document).off('.oc.popover') } Popover.prototype.show = function(options) { - var self = this; + var self = this /* * Trigger the show event */ var e = $.Event('showing.oc.popover', {relatedTarget: this.$el}) this.$el.trigger(e, this) - if (e.isDefaultPrevented()) + if (e.isDefaultPrevented()) return /* * Create the popover container and overlay */ - - this.$container = $('
') + this.$container = $('
') .addClass('control-popover') .css('visibility', 'hidden') if (this.options.containerClass) this.$container.addClass(this.options.containerClass) - var $content = $('
').html(this.getContent()) + var $content = $('
').html(this.getContent()) this.$container.append($content) if (this.options.width) this.$container.width(this.options.width) if (this.options.modal) { - this.$overlay = $('
').addClass('popover-overlay') + this.$overlay = $('
').addClass('popover-overlay') $(document.body).append(this.$overlay) if (this.options.highlightModalTarget) { this.$el.addClass('popover-highlight') this.$el.blur() } - } else + } else { this.$overlay = false + } if (this.options.container) $(this.options.container).append(this.$container); @@ -119,8 +119,7 @@ /* * Determine the popover position */ - - var + var placement = this.calcPlacement(), position = this.calcPosition(placement); @@ -132,14 +131,12 @@ /* * Display the popover */ - this.$container.css('visibility', 'visible') $(document.body).addClass('popover-open') /* * Bind events */ - this.$container.on('mousedown', function(e){ e.stopPropagation(); }) @@ -173,12 +170,12 @@ } Popover.prototype.calcDimensions = function() { - var + var documentWidth = $(document).width(), documentHeight = $(document).height(), targetOffset = this.$el.offset(), targetWidth = this.$el.outerWidth(), - targetHeight = this.$el.outerHeight(); + targetHeight = this.$el.outerHeight() return { containerWidth: this.$container.outerWidth() + this.arrowSize, @@ -242,24 +239,24 @@ } Popover.prototype.calcPosition = function(placement) { - var + var dimensions = this.calcDimensions(), - result; + result switch (placement) { - case 'left' : + case 'left': var realOffset = this.options.offsetY === undefined ? this.options.offset : this.options.offsetY result = {x: (dimensions.targetOffset.left - dimensions.containerWidth), y: dimensions.targetOffset.top + realOffset} break; - case 'top' : + case 'top': var realOffset = this.options.offsetX === undefined ? this.options.offset : this.options.offsetX result = {x: dimensions.targetOffset.left + realOffset, y: (dimensions.targetOffset.top - dimensions.containerHeight)} break; - case 'bottom' : + case 'bottom': var realOffset = this.options.offsetX === undefined ? this.options.offset : this.options.offsetX result = {x: dimensions.targetOffset.left + realOffset, y: (dimensions.targetOffset.top + dimensions.targetHeight + this.arrowSize)} break; - case 'right' : + case 'right': var realOffset = this.options.offsetY === undefined ? this.options.offset : this.options.offsetY result = {x: (dimensions.targetOffset.left + dimensions.targetWidth + this.arrowSize), y: dimensions.targetOffset.top + realOffset} break; @@ -268,14 +265,14 @@ if (!this.options.container) return result - var + var $container = $(this.options.container), - containerOffset = $container.offset(); + containerOffset = $container.offset() - result.x -= containerOffset.left; - result.y -= containerOffset.top; + result.x -= containerOffset.left + result.y -= containerOffset.top - return result; + return result } Popover.prototype.onDocumentClick = function() { diff --git a/modules/backend/assets/less/controls/filters.less b/modules/backend/assets/less/controls/filters.less index b9efd119f..e2b044678 100644 --- a/modules/backend/assets/less/controls/filters.less +++ b/modules/backend/assets/less/controls/filters.less @@ -10,11 +10,11 @@ border-top: 1px solid @color-filter-border; border-bottom: 1px solid @color-filter-border; a { + text-decoration: none; color: @color-filter-text; - text-decoration: none; } - >.filter-set { + > .filter-scope { display: inline-block; padding: 15px; .filter-label {} @@ -37,6 +37,17 @@ } } + &.checkbox { + padding-left: 35px; + &, label { + margin-bottom: 0; + } + + &:after { + content: ''; + } + } + &:hover { color: #000; .filter-label { color: @color-filter-text; } @@ -52,7 +63,7 @@ min-height: 36px; input { min-height: 36px; - border: none; + border: none; border-bottom: 1px solid @color-filter-border; background: transparent url(../images/bitmap-icons.png) no-repeat 100% -82px; .border-radius(0); @@ -67,7 +78,7 @@ text-decoration: none; color: @color-filter-text; display: block; - padding: 7px 15px; + padding: 7px 15px; &:before { margin-right: 8px; @@ -86,6 +97,19 @@ background-color: @color-filter-items-bg; border-bottom: 1px solid @color-filter-border; a:before { .icon(@plus); } + + li.loading { + padding: 7px; + > span { + display: block; + height: 20px; + width: 20px; + background-image: url(../images/loading-indicator.svg); + background-size: 20px 20px; + background-position: 50% 50%; + .animation(spin 1s linear infinite); + } + } } .filter-active-items { diff --git a/modules/backend/behaviors/ListController.php b/modules/backend/behaviors/ListController.php index 444a0f2fe..21853a607 100644 --- a/modules/backend/behaviors/ListController.php +++ b/modules/backend/behaviors/ListController.php @@ -36,6 +36,11 @@ class ListController extends ControllerBehavior */ protected $toolbarWidgets = []; + /** + * @var WidgetBase Reference to the filter widget objects. + */ + protected $filterWidgets = []; + /** * {@inheritDoc} */ @@ -174,6 +179,20 @@ class ListController extends ControllerBehavior $this->toolbarWidgets[$definition] = $toolbarWidget; } + /* + * Prepare the filter widget (optional) + */ + if (isset($listConfig->filter)) { + $filterConfig = $this->makeConfig($listConfig->filter); + $filterConfig->alias = $widget->alias . 'Filter'; + $filterWidget = $this->makeWidget('Backend\Widgets\Filter', $filterConfig); + $filterWidget->bindToController(); + + $widget->cssClasses[] = 'list-flush'; + + $this->filterWidgets[$definition] = $filterWidget; + } + return $widget; } @@ -206,6 +225,9 @@ class ListController extends ControllerBehavior if (isset($this->toolbarWidgets[$definition])) $collection[] = $this->toolbarWidgets[$definition]->render(); + if (isset($this->filterWidgets[$definition])) + $collection[] = $this->filterWidgets[$definition]->render(); + $collection[] = $this->listWidgets[$definition]->render(); return implode(PHP_EOL, $collection); diff --git a/modules/backend/classes/FilterScope.php b/modules/backend/classes/FilterScope.php new file mode 100644 index 000000000..ae6acca89 --- /dev/null +++ b/modules/backend/classes/FilterScope.php @@ -0,0 +1,118 @@ +scopeName = $scopeName; + $this->label = $label; + } + + /** + * Specifies a scope control rendering mode. Supported modes are: + * - group - filter by a group of IDs. Default. + * - checkbox - filter by a simple toggle switch. + * @param string $type Specifies a render mode as described above + * @param array $config A list of render mode specific config. + */ + public function displayAs($type, $config = []) + { + $this->type = strtolower($type) ?: $this->type; + $this->config = $this->evalConfig($config); + return $this; + } + + /** + * Process options and apply them to this object. + * @param array $config + * @return array + */ + protected function evalConfig($config) + { + if (isset($config['options'])) $this->options($config['options']); + if (isset($config['context'])) $this->context = $config['context']; + if (isset($config['default'])) $this->defaults = $config['default']; + if (isset($config['cssClass'])) $this->cssClass = $config['cssClass']; + + if (array_key_exists('disabled', $config)) $this->disabled = $config['disabled']; + return $config; + } + + /** + * Returns a value suitable for the scope id property. + */ + public function getId($suffix = null) + { + $id = 'scope'; + $id .= '-'.$this->scopeName; + + if ($suffix) + $id .= '-'.$suffix; + + if ($this->idPrefix) + $id = $this->idPrefix . '-' . $id; + + $id = rtrim(str_replace(['[', ']'], '-', $id), '-'); + return $id; + } + +} \ No newline at end of file diff --git a/modules/backend/widgets/Filter.php b/modules/backend/widgets/Filter.php index 6bc473ce3..a2ab2b199 100644 --- a/modules/backend/widgets/Filter.php +++ b/modules/backend/widgets/Filter.php @@ -1,6 +1,9 @@ activeContext = $this->getConfig('context'); + } + + /** + * {@inheritDoc} + */ + public function loadAssets() + { + $this->addJs('js/october.filter.js', 'core'); + } + /** * Renders the widget. */ @@ -30,5 +75,197 @@ class Filter extends WidgetBase */ public function prepareVars() { + $this->defineFilterScopes(); + $this->vars['cssClasses'] = implode(' ', $this->cssClasses); + $this->vars['scopes'] = $this->allScopes; } + + public function onFilterGetOptions() + { + $this->defineFilterScopes(); + + if (!$scopeName = post('scopeName')) + return; + + $scope = $this->getScope($scopeName); + + // $available = [ + // ['id' => 1, 'name' => 'Deleted'], + // ['id' => 2, 'name' => 'Moo'], + // ]; + + $available = $this->getAvailableOptions($scope); + $active = $this->filterActiveOptions($available); + + // $active = [ + // ['id' => 3, 'name' => 'Selected'], + // ['id' => 4, 'name' => 'Bar'], + // ]; + + $available = $this->processOptionsForAjax($available); + $active = $this->processOptionsForAjax($active); + + return [ + 'options' => [ + 'available' => $available, + 'active' => $active, + ] + ]; + } + + protected function getAvailableOptions($scope) + { + $available = []; + $options = $this->getOptionsFromModel($scope); + foreach ($options as $option) { + $available[$option->id] = $option->name; + } + return $available; + } + + protected function filterActiveOptions(&$availableOptions) + { + $fromSession = [1]; + + $active = []; + foreach ($availableOptions as $id => $option) { + if (!in_array($id, $fromSession)) + continue; + + $active[$id] = $option; + unset($availableOptions[$id]); + } + + return $active; + } + + protected function processOptionsForAjax($options) + { + $processed = []; + foreach ($options as $id => $result) { + $processed[] = ['id' => $id, 'name' => $result]; + } + return $processed; + } + + /** + * Looks at the model for defined scope items. + */ + protected function getOptionsFromModel($scope) + { + $model = $this->scopeModels[$scope->scopeName]; + return $model->all(); + } + + /** + * Renders the HTML element for a scope + */ + public function renderScopeElement($scope) + { + return $this->makePartial('scope_'.$scope->type, ['scope' => $scope]); + } + + /** + * Creates a flat array of filter scopes from the configuration. + */ + protected function defineFilterScopes() + { + if ($this->scopesDefined) + return; + + /* + * Extensibility + */ + Event::fire('backend.filter.extendScopesBefore', [$this]); + $this->fireEvent('filter.extendScopesBefore'); + + /* + * All scopes + */ + if (!isset($this->config->scopes) || !is_array($this->config->scopes)) + $this->config->scopes = []; + + $this->addScopes($this->config->scopes); + + /* + * Extensibility + */ + Event::fire('backend.filter.extendScopes', [$this]); + $this->fireEvent('filter.extendScopes'); + + $this->scopesDefined = true; + } + + /** + * Programatically add scopes, used internally and for extensibility. + */ + public function addScopes(array $scopes) + { + foreach ($scopes as $name => $config) { + + $scopeObj = $this->makeFilterScope($name, $config); + + /* + * Check that the filter scope matches the active context + */ + if ($scopeObj->context !== null) { + $context = (is_array($scopeObj->context)) ? $scopeObj->context : [$scopeObj->context]; + if (!in_array($this->getContext(), $context)) + continue; + } + + /* + * Validate scope model + */ + if (!isset($config['modelClass'])) + throw new ApplicationException('Missing model definition for scope'); + + $class = $config['modelClass']; + $model = new $class; + $this->scopeModels[$name] = $model; + + $this->allScopes[$name] = $scopeObj; + } + } + + /** + * Creates a filter scope object from name and configuration. + */ + protected function makeFilterScope($name, $config) + { + $label = (isset($config['label'])) ? $config['label'] : null; + $scopeType = isset($config['type']) ? $config['type'] : null; + + $scope = new FilterScope($name, $label); + $scope->displayAs($scopeType, $config); + return $scope; + } + + /** + * Get all the registered scopes for the instance. + * @return array + */ + public function getScopes() + { + return $this->allScopes; + } + + /** + * Get a specified scope object + * @param string $scope + * @return mixed + */ + public function getScope($scope) + { + return $this->allScopes[$scope]; + } + + /** + * Returns the active context for displaying the filter. + */ + public function getContext() + { + return $this->activeContext; + } + } \ No newline at end of file diff --git a/modules/backend/widgets/filter/assets/js/october.filter.js b/modules/backend/widgets/filter/assets/js/october.filter.js new file mode 100644 index 000000000..6f46d6b47 --- /dev/null +++ b/modules/backend/widgets/filter/assets/js/october.filter.js @@ -0,0 +1,167 @@ +/* + * Filter Widget + * + * Dependences: + * - Nil + */ ++function ($) { "use strict"; + + var FilterWidget = function (element, options) { + + var $el = this.$el = $(element); + + this.options = options || {} + this.scopeValues = {} + + this.init() + } + + FilterWidget.DEFAULTS = { + optionsHandler: null + } + + /* + * Get popover template + */ + FilterWidget.prototype.getPopoverTemplate = function() { + + return ' \ +
\ + \ +
\ +
    \ + {{#available}} \ +
  • {{name}}
  • \ + {{/available}} \ + {{^available}} \ +
  • \ + {{/available}} \ +
\ +
\ +
\ +
    \ + {{#active}} \ +
  • {{name}}
  • \ + {{/active}} \ +
\ +
\ +
\ + ' + } + + FilterWidget.prototype.init = function() { + var self = this + + this.$el.on('click', 'a.filter-scope', function(){ + self.displayPopover($(this)) + }) + } + + FilterWidget.prototype.displayPopover = function($scope) { + var self = this, + scopeName = $scope.data('scope-name'), + data = this.scopeValues[scopeName] + + if (!data) + self.loadOptions($scope) + + $scope.ocPopover({ + content: Mustache.render(self.getPopoverTemplate(), data), + modal: false, + highlightModalTarget: true, + closeOnPageClick: true, + placement: 'bottom' + }) + } + + FilterWidget.prototype.loadOptions = function($scope) { + var $form = this.$el.closest('form'), + self = this, + scopeName = $scope.data('scope-name'), + data = { + scopeName: scopeName + } + + $form.request(this.options.optionsHandler, { + data: data, + success: function(data) { + + if (self.scopeValues[scopeName]) + return + + self.scopeValues[scopeName] = data.options + + /* + * Inject available + */ + if (data.options.available) { + var container = $('.control-filter-popover .filter-items > ul').empty() + $.each(data.options.available, function(key, obj){ + var item = $('
  • ').append($('').prop({ + 'href': 'javascript:;', + 'data-item-id': obj.id + }).text(obj.name)) + container.append(item) + }) + } + + /* + * Inject active + */ + if (data.options.active) { + var container = $('.control-filter-popover .filter-active-items > ul') + $.each(data.options.active, function(key, obj){ + var item = $('
  • ').append($('').prop({ + 'href': 'javascript:;', + 'data-item-id': obj.id + }).text(obj.name)) + container.append(item) + }) + } + + } + }) + } + + // FILTER WIDGET PLUGIN DEFINITION + // ============================ + + var old = $.fn.filterWidget + + $.fn.filterWidget = function (option) { + var args = arguments, + result + + this.each(function () { + var $this = $(this) + var data = $this.data('oc.filterwidget') + var options = $.extend({}, FilterWidget.DEFAULTS, $this.data(), typeof option == 'object' && option) + if (!data) $this.data('oc.filterwidget', (data = new FilterWidget(this, options))) + if (typeof option == 'string') result = data[option].call($this) + if (typeof result != 'undefined') return false + }) + + return result ? result : this + } + + $.fn.filterWidget.Constructor = FilterWidget + + // FILTER WIDGET NO CONFLICT + // ================= + + $.fn.filterWidget.noConflict = function () { + $.fn.filterWidget = old + return this + } + + // FILTER WIDGET DATA-API + // ============== + + $(document).render(function(){ + $('[data-control="filterwidget"]').filterWidget(); + }) + +}(window.jQuery); + diff --git a/modules/backend/widgets/filter/partials/_filter.htm b/modules/backend/widgets/filter/partials/_filter.htm index 552cc716a..527e493f2 100644 --- a/modules/backend/widgets/filter/partials/_filter.htm +++ b/modules/backend/widgets/filter/partials/_filter.htm @@ -1,4 +1,9 @@ - + diff --git a/modules/backend/widgets/filter/partials/_filter_scopes.htm b/modules/backend/widgets/filter/partials/_filter_scopes.htm new file mode 100644 index 000000000..95bdf2f1d --- /dev/null +++ b/modules/backend/widgets/filter/partials/_filter_scopes.htm @@ -0,0 +1,3 @@ + + renderScopeElement($scope) ?> + diff --git a/modules/backend/widgets/filter/partials/_scope_checkbox.htm b/modules/backend/widgets/filter/partials/_scope_checkbox.htm new file mode 100644 index 000000000..835f93ad4 --- /dev/null +++ b/modules/backend/widgets/filter/partials/_scope_checkbox.htm @@ -0,0 +1,7 @@ + +
    + + +
    diff --git a/modules/backend/widgets/filter/partials/_scope_group.htm b/modules/backend/widgets/filter/partials/_scope_group.htm new file mode 100644 index 000000000..b5a51ec59 --- /dev/null +++ b/modules/backend/widgets/filter/partials/_scope_group.htm @@ -0,0 +1,8 @@ + + + label) ?>: + all +