Refs #5 - Progress to Filter widget

This commit is contained in:
Sam Georges 2014-08-11 21:46:29 +10:00
parent f18c905f9b
commit f1849c2ea0
12 changed files with 674 additions and 53 deletions

View File

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

View File

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

View File

@ -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 = $('<div/>')
this.$container = $('<div />')
.addClass('control-popover')
.css('visibility', 'hidden')
if (this.options.containerClass)
this.$container.addClass(this.options.containerClass)
var $content = $('<div/>').html(this.getContent())
var $content = $('<div />').html(this.getContent())
this.$container.append($content)
if (this.options.width)
this.$container.width(this.options.width)
if (this.options.modal) {
this.$overlay = $('<div/>').addClass('popover-overlay')
this.$overlay = $('<div />').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() {

View File

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

View File

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

View File

@ -0,0 +1,118 @@
<?php namespace Backend\Classes;
use Str;
/**
* Filter scope definition
* A translation of the filter scope configuration
*
* @package october\backend
* @author Alexey Bobkov, Samuel Georges
*/
class FilterScope
{
/**
* @var string Scope name.
*/
public $scopeName;
/**
* @var string A prefix to the field identifier so it can be totally unique.
*/
public $idPrefix;
/**
* @var string Form scope label.
*/
public $label;
/**
* @var string Filter mode.
*/
public $type = 'group';
/**
* @var string Filter options.
*/
public $options;
/**
* @var string Specifies contextual visibility of this form scope.
*/
public $context = null;
/**
* @var bool Specify if the scope is disabled or not.
*/
public $disabled = false;
/**
* @var string Specifies a default value for supported scopes.
*/
public $defaults;
/**
* @var string Specifies a CSS class to attach to the scope container.
*/
public $cssClass;
/**
* @var array Raw scope configuration.
*/
public $config;
public function __construct($scopeName, $label)
{
$this->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;
}
}

View File

@ -1,6 +1,9 @@
<?php namespace Backend\Widgets;
use Event;
use Backend\Classes\WidgetBase;
use Backend\Classes\FilterScope;
use System\Classes\ApplicationException;
/**
* Filter Widget
@ -16,6 +19,48 @@ class Filter extends WidgetBase
*/
public $defaultAlias = 'filter';
/**
* @var boolean Determines if scope definitions have been created.
*/
protected $scopesDefined = false;
/**
* @var array Collection of all scopes used in this filter.
*/
protected $allScopes = [];
/**
* @var array Collection of all scopes models used in this filter.
*/
protected $scopeModels = [];
/**
* @var string The context of this filter, scopes that do not belong
* to this context will not be shown.
*/
protected $activeContext = null;
/**
* @var array List of CSS classes to apply to the filter container element
*/
public $cssClasses = [];
/**
* Initialize the widget, called by the constructor and free from its parameters.
*/
public function init()
{
$this->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;
}
}

View File

@ -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 ' \
<div class="control-filter-popover"> \
<div class="filter-search"> \
<input type="text" class="form-control" /> \
</div> \
<div class="filter-items"> \
<ul> \
{{#available}} \
<li><a href="javascript:;" data-item-id="{{id}}">{{name}}</a></li> \
{{/available}} \
{{^available}} \
<li class="loading"><span></span></li> \
{{/available}} \
</ul> \
</div> \
<div class="filter-active-items"> \
<ul> \
{{#active}} \
<li><a href="javascript:;" data-item-id="{{id}}">{{name}}</a></li> \
{{/active}} \
</ul> \
</div> \
</div> \
'
}
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 = $('<li />').append($('<a />').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 = $('<li />').append($('<a />').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);

View File

@ -1,4 +1,9 @@
<div class="control-filter">
<div
class="control-filter"
data-control="filterwidget"
data-options-handler="<?= $this->getEventHandler('onFilterGetOptions') ?>">
<?= $this->makePartial('filter_scopes') ?>
<a class="filter-set" href="javascript:;">
<span class="filter-label">Categories:</span>
@ -10,8 +15,12 @@
<span class="filter-setting">2</span>
</a>
</div>
<div class="filter-scope checkbox custom-checkbox">
<input type="checkbox" id="hideTranslated" name="hide_translated" value="1" />
<label for="hideTranslated">Hide disabled</label>
</div>
</div>
<script>
$('.control-filter').on('click', 'a.filter-set', function(){
$(this).ocPopover({
@ -35,6 +44,10 @@
<ul>
<li><a href="#">Deleted</a></li>
<li><a href="#">Deployed</a></li>
<li><a href="#">Deployed</a></li>
<li><a href="#">Deployed</a></li>
<li><a href="#">Deployed</a></li>
<li><a href="#">Deployed</a></li>
<li><a href="#">Detailed</a></li>
</ul>
</div>
@ -46,3 +59,4 @@
</div>
</div>
</script>

View File

@ -0,0 +1,3 @@
<?php foreach ($scopes as $scope): ?>
<?= $this->renderScopeElement($scope) ?>
<?php endforeach ?>

View File

@ -0,0 +1,7 @@
<!-- Checkbox scope -->
<div
class="filter-scope checkbox custom-checkbox"
data-scope-name="<?= $scope->scopeName ?>">
<input type="checkbox" id="<?= $scope->getId() ?>" />
<label for="<?= $scope->getId() ?>"><?= e($scope->label) ?></label>
</div>

View File

@ -0,0 +1,8 @@
<!-- Group scope -->
<a
class="filter-scope"
href="javascript:;"
data-scope-name="<?= $scope->scopeName ?>">
<span class="filter-label"><?= e($scope->label) ?>:</span>
<span class="filter-setting">all</span>
</a>