diff --git a/bootstrap.php b/bootstrap.php index fe3dd2155b8fdd0e2dbb21d249d5eeba247fadc0..c00e425c35f8278b8f27d7fc1f9edfe01848e071 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -133,6 +133,7 @@ class Bootstrap { define('SEQUENCE_TABLE', $prefix.'sequence'); define('TRANSLATION_TABLE', $prefix.'translation'); define('QUEUE_TABLE', $prefix.'queue'); + define('QUEUE_COLUMN_TABLE', $prefix.'queue_column'); define('API_KEY_TABLE',$prefix.'api_key'); define('TIMEZONE_TABLE',$prefix.'timezone'); diff --git a/include/ajax.search.php b/include/ajax.search.php index f2175a76dc4b961d7c260b823a8675f4e40fc424..40cd7311050d761e2ad71d76cadbc295db290599 100644 --- a/include/ajax.search.php +++ b/include/ajax.search.php @@ -19,6 +19,7 @@ if(!defined('INCLUDE_DIR')) die('403'); include_once(INCLUDE_DIR.'class.ticket.php'); require_once(INCLUDE_DIR.'class.ajax.php'); +require_once(INCLUDE_DIR.'class.queue.php'); class SearchAjaxAPI extends AjaxController { @@ -30,7 +31,7 @@ class SearchAjaxAPI extends AjaxController { $search = SavedSearch::create(); $form = $search->getFormFromSession('advsearch') ?: $search->getForm(); - $matches = self::_getSupportedTicketMatches(); + $matches = SavedSearch::getSupportedTicketMatches(); include STAFFINC_DIR . 'templates/advanced-search.tmpl.php'; } @@ -96,7 +97,7 @@ class SearchAjaxAPI extends AjaxController { $form = $search->getForm($_POST); if (!$form->isValid()) { - $matches = self::_getSupportedTicketMatches(); + $matches = SavedSearch::getSupportedTicketMatches(); include STAFFINC_DIR . 'templates/advanced-search.tmpl.php'; return; } @@ -148,41 +149,6 @@ class SearchAjaxAPI extends AjaxController { ))); } - function _getSupportedTicketMatches() { - // User information - $matches = array( - __('Ticket Built-In') => SavedSearch::getExtendedTicketFields(), - __('Custom Forms') => array() - ); - foreach (array('ticket'=>'TicketForm', 'user'=>'UserForm', 'organization'=>'OrganizationForm') as $k=>$F) { - $form = $F::objects()->one(); - $fields = &$matches[$form->getLocal('title')]; - foreach ($form->getFields() as $f) { - if (!$f->hasData() || $f->isPresentationOnly()) - continue; - $fields[":$k!".$f->get('id')] = __(ucfirst($k)).' / '.$f->getLocal('label'); - /* TODO: Support matches on list item properties - if (($fi = $f->getImpl()) && $fi->hasSubFields()) { - foreach ($fi->getSubFields() as $p) { - $fields[":$k.".$f->get('id').'.'.$p->get('id')] - = __(ucfirst($k)).' / '.$f->getLocal('label').' / '.$p->getLocal('label'); - } - } - */ - } - } - $fields = &$matches[__('Custom Forms')]; - foreach (DynamicForm::objects()->filter(array('type'=>'G')) as $form) { - foreach ($form->getFields() as $f) { - if (!$f->hasData() || $f->isPresentationOnly()) - continue; - $key = sprintf(':field!%d', $f->get('id'), $f->get('id')); - $fields[$key] = $form->getLocal('title').' / '.$f->getLocal('label'); - } - } - return $matches; - } - function createSearch() { global $thisstaff; @@ -208,7 +174,7 @@ class SearchAjaxAPI extends AjaxController { $form = $search->loadFromState($state); $form->loadState($state); } - $matches = self::_getSupportedTicketMatches(); + $matches = SavedSearch::getSupportedTicketMatches(); include STAFFINC_DIR . 'templates/advanced-search.tmpl.php'; } @@ -231,4 +197,120 @@ class SearchAjaxAPI extends AjaxController { 'success' => true, ))); } + + + function editColumn($queue_id, $column) { + global $thisstaff; + + if (!$thisstaff) { + Http::response(403, 'Agent login is required'); + } + elseif (!($queue = CustomQueue::lookup($queue_id))) { + Http::response(404, 'No such queue'); + } + + $data_form = new QueueDataConfigForm($_POST); + include STAFFINC_DIR . 'templates/queue-column.tmpl.php'; + } + + function previewQueue($id=false) { + global $thisstaff; + + if (!$thisstaff) { + Http::response(403, 'Agent login is required'); + } + if ($id && (!($queue = CustomQueue::lookup($id)))) { + Http::response(404, 'No such queue'); + } + + if (!$queue) { + $queue = CustomQueue::create(); + } + + $form = $queue->getForm($_POST); + + // TODO: Update queue columns (but without save) + foreach ($_POST['columns'] as $colid) { + $col = QueueColumn::create(array("id" => $colid)); + $col->update($_POST); + $queue->addColumn($col); + } + + $tickets = $queue->getQuery($form); + $count = 10; // count($queue->getBasicQuery($form)); + + include STAFFINC_DIR . 'templates/queue-tickets.tmpl.php'; + } + + function addCondition() { + global $thisstaff; + + if (!$thisstaff) { + Http::response(403, 'Agent login is required'); + } + elseif (!isset($_GET['field'])) { + Http::response(400, '`field` parameter is required'); + } + $fields = SavedSearch::getSearchableFields('Ticket'); + if (!isset($fields[$_GET['field']])) { + Http::response(400, sprintf('%s: No such searchable field'), + Format::htmlchars($_GET['field'])); + } + + $field = $fields[$_GET['field']]; + $condition = new QueueColumnCondition(); + include STAFFINC_DIR . 'templates/queue-column-condition.tmpl.php'; + } + + function addConditionProperty() { + global $thisstaff; + + if (!$thisstaff) { + Http::response(403, 'Agent login is required'); + } + elseif (!isset($_GET['prop'])) { + Http::response(400, '`prop` parameter is required'); + } + + $prop = $_GET['prop']; + include STAFFINC_DIR . 'templates/queue-column-condition-prop.tmpl.php'; + } + + function addColumn() { + global $thisstaff; + + if (!$thisstaff) { + Http::response(403, 'Agent login is required'); + } + elseif (!isset($_GET['field'])) { + Http::response(400, '`field` parameter is required'); + } + + $field = $_GET['field']; + // XXX: This method should receive a queue ID or queue root so that + // $field can be properly checked + $fields = SavedSearch::getSearchableFields('Ticket'); + if (!isset($fields[$field])) { + Http::response(400, 'Not a supported field for this queue'); + } + + // Get the tabbed column configuration + $F = $fields[$field]; + $column = QueueColumn::create(array( + "id" => (int) $_GET['id'], + "heading" => _S($F->getLabel()), + "primary" => $field, + "width" => 100, + )); + ob_start(); + include STAFFINC_DIR . 'templates/queue-column.tmpl.php'; + $config = ob_get_clean(); + + // Send back the goodies + Http::response(200, $this->encode(array( + 'config' => $config, + 'heading' => _S($F->getLabel()), + 'width' => $column->getWidth(), + )), 'application/json'); + } } diff --git a/include/class.dept.php b/include/class.dept.php index 8f09237c4b37fc721d4a9a25367984414e9e9afd..b54913af42ad14783b1b852fc55a9efaa7dab532 100644 --- a/include/class.dept.php +++ b/include/class.dept.php @@ -13,9 +13,10 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ +require_once INCLUDE_DIR . 'class.search.php'; class Dept extends VerySimpleModel -implements TemplateVariable { +implements TemplateVariable, Searchable { static $meta = array( 'table' => DEPT_TABLE, @@ -98,6 +99,17 @@ implements TemplateVariable { } } + static function getSearchableFields() { + return array( + 'name' => new TextboxField(array( + 'label' => __('Name'), + )), + 'manager' => new AgentSelectionField(array( + 'label' => __('Manager'), + )), + ); + } + function getId() { return $this->id; } diff --git a/include/class.forms.php b/include/class.forms.php index c097dd995ab0f8e48336215bb6acf578e0dfb440..9171b5f90978ab2e4cd4d639ad2d08dffcba564e 100644 --- a/include/class.forms.php +++ b/include/class.forms.php @@ -47,7 +47,10 @@ class Form { } function getId() { - return static::$id; + return @$this->id ?: static::$id; + } + function setId($id) { + $this->id = $id; } function data($source) { @@ -998,6 +1001,19 @@ class FormField { return sprintf($desc, $name, $value); } + function addToQuery($query, $name=false) { + return $query->values($name ?: $this->get('name')); + } + + /** + * Similary to to_php() and parse(), except a row from a queryset is + * passed. The value returned should be what would be retured from + * parse() or to_php() + */ + function from_query($row, $name=false) { + return $row[$name ?: $this->get('name')]; + } + function getLabel() { return $this->get('label'); } /** @@ -1042,12 +1058,25 @@ class FormField { $this->getWidget()->value = $value; } + /** + * Fetch a pseudo-random id for this form field. It is used when + * rendering the widget in the @name attribute emitted in the resulting + * HTML. The form element is based on the form id, field id and name, + * and the current user's session id. Therefor, the same form fields + * will yield differing names for different users. This is used to ward + * off bot attacks as it makes it very difficult to predict and + * correlate the form names to the data they represent. + */ function getFormName() { - if (is_numeric($this->get('id'))) + $default = $this->get('name') ?: $this->get('id'); + if ($this->_form && is_numeric($fid = $this->_form->getId())) + return substr(md5( + session_id() . '-form-field-id-' . $fid . $default), -14); + elseif (is_numeric($this->get('id'))) return substr(md5( session_id() . '-field-id-'.$this->get('id')), -16); - else - return $this->get('name') ?: $this->get('id'); + + return $default; } function setForm($form) { @@ -1173,17 +1202,6 @@ class FormField { return null; } - /** - * Indicates if the field provides for searching for something other - * than keywords. For instance, textbox fields can have hits by keyword - * searches alone, but selection fields should provide the option to - * match a specific value or set of values and therefore need to - * participate on any search builder. - */ - function hasSpecialSearch() { - return true; - } - function getConfigurationForm($source=null) { if (!$this->_cform) { $type = static::getFieldType($this->get('type')); @@ -1292,10 +1310,6 @@ class TextboxField extends FormField { ); } - function hasSpecialSearch() { - return false; - } - function validateEntry($value) { parent::validateEntry($value); $config = $this->getConfiguration(); @@ -1379,10 +1393,6 @@ class TextareaField extends FormField { ); } - function hasSpecialSearch() { - return false; - } - function display($value) { $config = $this->getConfiguration(); if ($config['html']) @@ -1434,10 +1444,6 @@ class PhoneField extends FormField { ); } - function hasSpecialSearch() { - return false; - } - function validateEntry($value) { parent::validateEntry($value); $config = $this->getConfiguration(); @@ -1847,6 +1853,10 @@ class DatetimeField extends FormField { $value, $datetime->format('T')); } + function from_query($row, $name=false) { + return strtotime(parent::from_query($row, $name)); + } + function format($timestamp, $timezone=false) { if (!$timestamp || $timestamp <= 0) @@ -2133,10 +2143,6 @@ class ThreadEntryField extends FormField { function isPresentationOnly() { return true; } - function hasSpecialSearch() { - return false; - } - function getMedia() { $config = $this->getConfiguration(); $media = parent::getMedia() ?: array(); @@ -2753,10 +2759,6 @@ class FileUploadField extends FormField { ); } - function hasSpecialSearch() { - return false; - } - /** * Called from the ajax handler for async uploads via web clients. */ @@ -3037,6 +3039,10 @@ class FileFieldAttachments { } } +class ColorChoiceField extends FormField { + static $widget = 'ColorPickerWidget'; +} + class InlineFormData extends ArrayObject { var $_form; @@ -4094,6 +4100,27 @@ class FreeTextWidget extends Widget { } } +class ColorPickerWidget extends Widget { + static $media = array( + 'css' => array( + 'css/spectrum.css', + ), + 'js' => array( + 'js/spectrum.js', + ), + ); + + function render($options=array()) { + ?><input type="color" + id="<?php echo $this->id; ?>" + <?php echo implode(' ', array_filter(array( + $classes + ))); ?> + name="<?php echo $this->name; ?>" + value="<?php echo Format::htmlchars($this->value); ?>"/><?php + } +} + class VisibilityConstraint { static $operators = array( 'eq' => 1, diff --git a/include/class.list.php b/include/class.list.php index 69106502be674220c0f2c36c2293932554245471..20bc6b87b2ce4269cfd26550e41744e13ab482b7 100644 --- a/include/class.list.php +++ b/include/class.list.php @@ -1094,7 +1094,7 @@ CustomListHandler::register('ticket-status', 'TicketStatusList'); class TicketStatus extends VerySimpleModel -implements CustomListItem, TemplateVariable { +implements CustomListItem, TemplateVariable, Searchable { static $meta = array( 'table' => TICKET_STATUS_TABLE, @@ -1273,6 +1273,18 @@ implements CustomListItem, TemplateVariable { return $base; } + // Searchable interface + static function getSearchableFields() { + return array( + 'state' => new TicketStateChoiceField(array( + 'label' => __('State'), + )), + 'name' => new TicketStatusChoiceField(array( + 'label' => __('Status Name'), + )), + ); + } + function getList() { if (!isset($this->_list)) $this->_list = DynamicList::lookup(array('type' => 'ticket-status')); diff --git a/include/class.organization.php b/include/class.organization.php index f41e04225c5ce1bf017b647644c7e73d99f3a968..db49618cb95d13701fa65e5a6ca95c394d27de07 100644 --- a/include/class.organization.php +++ b/include/class.organization.php @@ -16,6 +16,7 @@ require_once(INCLUDE_DIR . 'class.orm.php'); require_once(INCLUDE_DIR . 'class.forms.php'); require_once(INCLUDE_DIR . 'class.dynamic_forms.php'); require_once(INCLUDE_DIR . 'class.user.php'); +require_once INCLUDE_DIR . 'class.search.php'; class OrganizationModel extends VerySimpleModel { static $meta = array( @@ -160,7 +161,7 @@ class OrganizationCdata extends VerySimpleModel { } class Organization extends OrganizationModel -implements TemplateVariable { +implements TemplateVariable, Searchable { var $_entries; var $_forms; @@ -341,6 +342,20 @@ implements TemplateVariable { return $base + $extra; } + static function getSearchableFields() { + $uform = OrganizationForm::objects()->one(); + foreach ($uform->getFields() as $F) { + $fname = $F->get('name') ?: ('field_'.$F->get('id')); + if (!$F->hasData() || $F->isPresentationOnly()) + continue; + if (!$F->isStorable()) + $base[$fname] = $F; + else + $base["cdata__{$fname}"] = $F; + } + return $base; + } + function update($vars, &$errors) { $valid = true; @@ -446,6 +461,15 @@ implements TemplateVariable { return true; } + static function getLink($id) { + global $thisstaff; + + if (!$id || !$thisstaff) + return false; + + return ROOT_PATH . sprintf('orgs.php?id=%s', $id); + } + static function fromVars($vars) { $vars['name'] = Format::striptags($vars['name']); diff --git a/include/class.orm.php b/include/class.orm.php index 7539c144564aba7d5c5922632fe341dc4e9abd9e..3a51e8cdccffb525c949422f186c49b39b45baae 100644 --- a/include/class.orm.php +++ b/include/class.orm.php @@ -488,6 +488,27 @@ class VerySimpleModel { return ($key) ? $M->offsetGet($key) : $M; } + static function getOrmFields($recurse=false) { + $fks = $lfields = $fields = array(); + $myname = get_called_class(); + foreach (static::getMeta('joins') as $name=>$j) { + $fks[$j['local']] = true; + if (!$j['reverse'] && !$j['list'] && $recurse) { + foreach ($j['fkey'][0]::getOrmFields($recurse - 1) as $name2=>$f) { + $fields["{$name}__{$name2}"] = "{$name} / $f"; + } + } + } + foreach (static::getMeta('fields') as $f) { + if (isset($fks[$f])) + continue; + if (in_array($f, static::getMeta('pk'))) + continue; + $lfields[$f] = "{$f}"; + } + return $lfields + $fields; + } + /** * objects * @@ -794,17 +815,23 @@ class SqlCase extends SqlFunction { class SqlExpr extends SqlFunction { function __construct($args) { - $this->args = $args; + $this->args = (array) $args; } function toSql($compiler, $model=false, $alias=false) { $O = array(); foreach ($this->args as $field=>$value) { - list($field, $op) = $compiler->getField($field, $model); - if (is_callable($op)) - $O[] = call_user_func($op, $field, $value, $model); - else - $O[] = sprintf($op, $field, $compiler->input($value)); + if ($value instanceof Q) { + $ex = $compiler->compileQ($value); + $O[] = $ex->text; + } + else { + list($field, $op) = $compiler->getField($field, $model); + if (is_callable($op)) + $O[] = call_user_func($op, $field, $value, $model); + else + $O[] = sprintf($op, $field, $compiler->input($value)); + } } return implode(' ', $O) . ($alias ? ' AS ' . $alias : ''); } @@ -1951,10 +1978,10 @@ extends ModelResultSet { return true; } - // QuerySet delegates function count() { - return $this->objects()->count(); + return count($this->asArray()); } + // QuerySet delegates function exists() { return $this->queryset->exists(); } @@ -1977,7 +2004,8 @@ extends ModelResultSet { } function offsetSet($a, $b) { $this->fillTo($a); - $this->cache[$a]->delete(); + if ($obj = $this->cache[$a]) + $obj->delete(); $this->add($b, $a); } diff --git a/include/class.queue.php b/include/class.queue.php new file mode 100644 index 0000000000000000000000000000000000000000..38bf53236bd82d4159345fd449827f8aa449f53e --- /dev/null +++ b/include/class.queue.php @@ -0,0 +1,688 @@ +<?php +/********************************************************************* + class.queue.php + + Custom (ticket) queues for osTicket + + Jared Hancock <jared@osticket.com> + Peter Rotich <peter@osticket.com> + Copyright (c) 2006-2013 osTicket + http://www.osticket.com + + Released under the GNU General Public License WITHOUT ANY WARRANTY. + See LICENSE.TXT for details. + + vim: expandtab sw=4 ts=4 sts=4: +**********************************************************************/ +require_once INCLUDE_DIR . 'class.search.php'; + +class CustomQueue extends SavedSearch { + static $meta = array( + 'joins' => array( + 'columns' => array( + 'reverse' => 'QueueColumn.queue', + ), + ), + ); + + static function objects() { + return parent::objects()->filter(array( + 'flags__hasbit' => static::FLAG_QUEUE + )); + } + + static function getDecorations($root) { + // Ticket decorations + return array( + 'TicketThreadCount', + 'ThreadAttachmentCount', + 'OverdueFlagDecoration', + 'TicketSourceDecoration' + ); + } + + function getColumns() { + if (!count($this->columns)) { + foreach (array( + new QueueColumn(array( + "id" => 1, + "heading" => "Number", + "primary" => 'number', + "width" => 100, + )), + new QueueColumn(array( + "id" => 2, + "heading" => "Created", + "primary" => 'created', + "width" => 100, + )), + new QueueColumn(array( + "id" => 3, + "heading" => "Subject", + "primary" => 'cdata__subject', + "width" => 250, + )), + new QueueColumn(array( + "id" => 4, + "heading" => "From", + "primary" => 'user__name', + "width" => 150, + )), + new QueueColumn(array( + "id" => 5, + "heading" => "Priority", + "primary" => 'cdata__priority', + "width" => 120, + )), + new QueueColumn(array( + "id" => 6, + "heading" => "Assignee", + "primary" => 'assignee', + "secondary" => 'team__name', + "width" => 100, + )), + ) as $c) { + $this->addColumn($c); + } + } + return $this->columns; + } + + function addColumn(QueueColumn $col) { + $this->columns->add($col); + $col->queue = $this; + } + + function getRoot() { + return 'Ticket'; + } + + function getBasicQuery($form=false) { + $root = $this->getRoot(); + $query = $root::objects(); + return $this->mangleQuerySet($query, $form); + } + + /** + * Retrieve a QuerySet instance based on the type of object (root) of + * this Q, which is automatically configured with the data and criteria + * of the queue and its columns. + * + * Returns: + * <QuerySet> instance + */ + function getQuery($form=false) { + $query = $this->getBasicQuery($form); + foreach ($this->getColumns() as $C) { + $query = $C->mangleQuery($query); + } + return $query; + } +} + +abstract class QueueDecoration { + static $icon = false; + static $desc = ''; + + var $config; + + function __construct($config) { + $this->config = $config; + } + + static function fromJson($config) { + $class = $config['c']; + if (class_exists($class)) + return new $class($config); + } + + static function getDescription() { + return __(static::$desc); + } + static function getIcon() { + return static::$icon; + } + static function getPositions() { + return array( + "<" => __('Start'), + "b" => __('Before'), + "a" => __('After'), + ">" => __('End'), + ); + } + + function decorate($text, $dec) { + static $positions = array( + '<' => '<span class="pull-left">%2$s</span>%1$s', + '>' => '<span class="pull-right">%2$s</span>%1$s', + 'a' => '%1$s %2$s', + 'b' => '%2$s %1$s', + ); + + $pos = strtolower($this->config['p']); + if (!isset($positions[$pos])) + return $text; + + return sprintf($positions[$pos], $text, $dec); + } + + // Render the annotation with the database record $row. $text is the + // text of the cell before decorations were applied. + function render($row, $cell) { + if ($decoration = $this->getDecoration($row, $cell)) + return $this->decorate($cell, $decoration); + + return $cell; + } + + // Add the annotation to a QuerySet + abstract function annotate($query); + + // Fetch some HTML to render the decoration on the page. This function + // can return boolean FALSE to indicate no decoration should be applied + abstract function getDecoration($row, $text); + + function getPosition() { + return strtolower($this->config['p']) ?: 'a'; + } +} + +class TicketThreadCount +extends QueueDecoration { + static $icon = 'comments-alt'; + static $qname = '_thread_count'; + static $desc = /* @trans */ 'Thread Count'; + + function annotate($query) { + return $query->annotate(array( + static::$qname => TicketThread::objects() + ->filter(array('ticket__ticket_id' => new SqlField('ticket_id', 1))) + ->exclude(array('entries__flags__hasbit' => ThreadEntry::FLAG_HIDDEN)) + ->aggregate(array('count' => SqlAggregate::COUNT('entries__id'))) + )); + } + + function getDecoration($row, $text) { + $threadcount = $row[static::$qname]; + if ($threadcount > 1) { + return sprintf( + '<i class="icon-comments-alt"></i><small>%s</small>', + $threadcount + ); + } + } +} + +class ThreadAttachmentCount +extends QueueDecoration { + static $icon = 'paperclip'; + static $qname = '_att_count'; + static $desc = /* @trans */ 'Attachment Count'; + + function annotate($query) { + // TODO: Convert to Thread attachments + return $query->annotate(array( + static::$qname => TicketThread::objects() + ->filter(array('ticket__ticket_id' => new SqlField('ticket_id', 1))) + ->filter(array('entries__attachments__inline' => 0)) + ->aggregate(array('count' => SqlAggregate::COUNT('entries__attachments__id'))) + )); + } + + function getDecoration($row, $text) { + $count = $row[static::$qname]; + if ($count) { + return sprintf( + '<i class="small icon-paperclip icon-flip-horizontal" data-toggle="tooltip" title="%s"></i>', + $count); + } + } +} + +class OverdueFlagDecoration +extends QueueDecoration { + static $icon = 'exclamation'; + static $desc = /* @trans */ 'Overdue Icon'; + + function annotate($query) { + return $query->values('isoverdue'); + } + + function getDecoration($row, $text) { + return sprintf( + '<span class="Icon overdueTicket">%s</span>', + $text); + } +} + +class TicketSourceDecoration +extends QueueDecoration { + static $icon = 'phone'; + static $desc = /* @trans */ 'Ticket Source'; + + function annotate($query) { + return $query->values('source'); + } + + function getDecoration($row, $text) { + return sprintf( + '<span class="Icon %sTicket">%s</span>', + $row['source'], $text); + } +} + +class DataSourceField +extends ChoiceField { + function getChoices() { + $config = $this->getConfiguration(); + $root = $config['root']; + $fields = array(); + foreach (SavedSearch::getSearchableFields($root) as $path=>$f) { + $fields[$path] = $f->get('label'); + } + return $fields; + } +} + +class QueueColumnCondition { + var $config; + var $properties = array(); + + function __construct($config) { + $this->config = $config; + if (is_array($config['prop'])) + $this->properties = $config['prop']; + } + + function getProperties() { + return $this->properties; + } + + function getField() { + } + + // Add the annotation to a QuerySet + function annotate($query) { + $criteria = $this->config['crit']; + $searchable = SavedSearch::getSearchableFields('Ticket'); + + // Setup a dummy form with a source for field setup + $form = new Form($criteria); + $fields = array(); + foreach ($criteria as $k=>$v) { + if (substr($k, -7) === '+method') { + list($name,) = explode('+', $k, 2); + if (!isset($searchable[$name])) + continue; + + // Lookup the field to search this condition + $field = $searchable[$name]; + + // Get the search method and value + $breakout = SavedSearch::getSearchField($field, $name); + $method = $breakout["{$name}+method"]; + $method->setForm($form); + if (!($method = $method->getClean())) + continue; + + if (!($value = $breakout["{$name}+{$method}"])) + continue; + + // Fetch a criteria Q for the query + $value = $value->getClean(); + $Q = $field->getSearchQ($method, $value, $name); + + // Add an annotation to the query + $query = $query->annotate(array( + $this->getAnnotationName() => new SqlExpr($Q) + )); + + // Only one field can be considered in the condition + break; + } + } + return $query; + } + + function render($row, $text) { + $field = $this->getAnnotationName(); + if ($V = $row[$field]) { + $style = array(); + foreach ($this->getProperties() as $css=>$value) { + $style[] = "{$css}:{$value}"; + } + $text = sprintf('<span style="%s">%s</span>', + implode(' ', $style), $text); + } + return $text; + } + + function getAnnotationName() { + return 'howdy'; + } + + static function fromJson($config) { + if (is_string($config)) + $config = JsonDataParser::decode($cnofig); + if (!is_array($config)) + throw new BadMethodCallException('$config must be string or array'); + + return new static($config); + } +} + +class QueueColumnConditionProperty +extends ChoiceField { + static $properties = array( + 'background-color' => 'ColorChoiceField', + 'color' => 'ColorChoiceField', + 'font-family' => array( + 'monospace', 'serif', 'sans-serif', 'cursive', 'fantasy', + ), + 'font-size' => array( + 'small', 'medium', 'large', 'smaller', 'larger', + ), + 'font-style' => array( + 'normal', 'italic', 'oblique', + ), + 'font-weight' => array( + 'lighter', 'normal', 'bold', 'bolder', + ), + 'text-decoration' => array( + 'none', 'underline', + ), + 'text-transform' => array( + 'uppercase', 'lowercase', 'captalize', + ), + ); + + function __construct($property) { + $this->property = $property; + } + + static function getProperties() { + return array_keys(static::$properties[$this->property]); + } + + static function getField($prop) { + $choices = static::$properties[$prop]; + if (is_array($choices)) + return new ChoiceField(array( + 'choices' => array_combine($choices, $choices), + )); + elseif (class_exists($choices)) + return new $choices(); + } + + function getChoices() { + if (isset($this->property)) + return static::$properties[$this->property]; + + $keys = array_keys(static::$properties); + return array_combine($keys, $keys); + } +} + + +/** + * Object version of JSON-serialized column array which has several + * properties: + * + * { + * "heading": "Header Text", + * "primary": "user__name", + * "secondary": null, + * "width": 100, + * "link": 'ticket', + * "truncate": "wrap", + * "filter": "UsersName" + * "annotations": [ + * { + * "c": "ThreadCollabCount", + * "p": ">" + * } + * ], + * "conditions": [ + * { + * "crit": { + * "created+method": {"ndaysago": "in the last n days"}, "created+ndaysago": {"until":"7"} + * }, + * "prop": { + * "font-weight": "bold" + * } + * } + * ] + * } + */ +class QueueColumn +extends VerySimpleModel { + static $meta = array( + 'table' => QUEUE_COLUMN_TABLE, + 'pk' => array('id'), + 'joins' => array( + 'queue' => array( + 'constraint' => array('queue_id' => 'CustomQueue.id'), + ), + ), + ); + + var $_decorations = array(); + var $_conditions = array(); + + function __onload() { + if ($this->annotations) { + foreach ($this->annotations as $D) + $this->_decorations[] = QueueDecoration::fromJson($D) ?: array(); + } + if ($this->conditions) { + foreach ($this->conditions as $C) + $this->_conditions[] = QueueColumnCondition::fromJson($C) ?: array(); + } + } + + function getId() { + return $this->id; + } + + function getQueue() { + return $this->queue; + } + + function getHeading() { + return $this->heading; + } + + function getWidth() { + return $this->width ?: 100; + } + + function getLink($row) { + $link = $this->link; + switch (strtolower($link)) { + case 'root': + case 'ticket': + return Ticket::getLink($row['ticket_id']); + case 'user': + return User::getLink($row['user_id']); + case 'org': + return Organization::getLink($row['user__org_id']); + } + } + + function render($row) { + // Basic data + $text = $this->renderBasicValue($row); + + // Truncate + if ($class = $this->getTruncateClass()) { + $text = sprintf('<span class="%s">%s</span>', $class, $text); + } + + // Link + if ($link = $this->getLink($row)) { + $text = sprintf('<a href="%s">%s</a>', $link, $text); + } + + // Decorations and conditions + foreach ($this->_decorations as $D) { + $text = $D->render($row, $text); + } + foreach ($this->_conditions as $C) { + $text = $C->render($row, $text); + } + return $text; + } + + function renderBasicValue($row) { + $root = $this->getQueue()->getRoot(); + $fields = SavedSearch::getSearchableFields($root); + $primary = $this->getOrmPath($this->primary); + $secondary = $this->getOrmPath($this->secondary); + + // TODO: Consider data filter if configured + + if (($F = $fields[$primary]) && ($T = $F->from_query($row, $primary))) + return $F->display($F->to_php($T)); + + if (($F = $fields[$secondary]) && ($T = $F->from_query($row, $secondary))) + return $F->display($F->to_php($T)); + } + + function getTruncateClass() { + switch ($this->truncate) { + case 'ellipsis': + return 'trucate'; + case 'clip': + return 'truncate clip'; + default: + case 'wrap': + return false; + } + } + + function mangleQuery($query) { + // Basic data + $fields = SavedSearch::getSearchableFields($this->getQueue()->getRoot()); + if ($primary = $fields[$this->primary]) + $query = $primary->addToQuery($query, + $this->getOrmPath($this->primary)); + + if ($secondary = $fields[$this->secondary]) + $query = $secondary->addToQuery($query, + $this->getOrmPath($this->secondary)); + + switch ($this->link) { + // XXX: Consider the ROOT of the related queue + case 'ticket': + $query = $query->values('ticket_id'); + break; + case 'user': + $query = $query->values('user_id'); + break; + case 'org': + $query = $query->values('user__org_id'); + break; + } + + // Decorations + foreach ($this->_decorations as $D) { + $query = $D->annotate($query); + } + + // Conditions + foreach ($this->_conditions as $C) { + $query = $C->annotate($query); + } + + return $query; + } + + function getDataConfigForm($source=false) { + return new QueueColDataConfigForm($source ?: $this->ht, + array('id' => $this->id)); + } + + function getOrmPath($name) { + return $name; + } + + function getDecorations() { + return $this->_decorations; + } + + function getConditions() { + return $this->_conditions; + } + + /** + * Create a CustomQueueColumn from vars (_POST) received from an + * update request. + */ + static function create($vars=array()) { + $inst = parent::create($vars); + // TODO: Convert decorations and conditions + return $inst; + } + + function update($vars) { + $form = $this->getDataConfigForm($vars); + foreach ($form->getClean() as $k=>$v) + $this->set($k, $v); + } +} + +class QueueColDataConfigForm +extends AbstractForm { + function buildFields() { + return array( + 'primary' => new DataSourceField(array( + 'label' => __('Primary Data Source'), + 'required' => true, + 'configuration' => array( + 'root' => 'Ticket', + ), + 'layout' => new GridFluidCell(6), + )), + 'secondary' => new DataSourceField(array( + 'label' => __('Secondary Data Source'), + 'configuration' => array( + 'root' => 'Ticket', + ), + 'layout' => new GridFluidCell(6), + )), + 'heading' => new TextboxField(array( + 'label' => __('Heading'), + 'required' => true, + 'layout' => new GridFluidCell(3), + )), + 'link' => new ChoiceField(array( + 'label' => __('Link'), + 'required' => false, + 'choices' => array( + 'ticket' => __('Ticket'), + 'user' => __('User'), + 'org' => __('Organization'), + ), + 'layout' => new GridFluidCell(3), + )), + 'width' => new TextboxField(array( + 'label' => __('Width'), + 'default' => 75, + 'configuration' => array( + 'validator' => 'number', + ), + 'layout' => new GridFluidCell(3), + )), + 'truncate' => new ChoiceField(array( + 'label' => __('Text Overflow'), + 'choices' => array( + 'wrap' => __("Wrap Lines"), + 'ellipsis' => __("Add Ellipsis"), + 'clip' => __("Clip Text"), + ), + 'default' => 'wrap', + 'layout' => new GridFluidCell(3), + )), + ); + } +} diff --git a/include/class.role.php b/include/class.role.php index 0e0f89af2b493003fe2ef3437a0e554152325c36..0b34853220e1ea29bb2c607a646b1063c50efac9 100644 --- a/include/class.role.php +++ b/include/class.role.php @@ -13,6 +13,7 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ +require_once INCLUDE_DIR . 'class.forms.php'; class RoleModel extends VerySimpleModel { static $meta = array( diff --git a/include/class.search.php b/include/class.search.php index 61b56a41e61f6c14dd4fa44ccaa6fd914d36453b..674533e2a531748f9bfb8a562d34777ed45b4bee 100644 --- a/include/class.search.php +++ b/include/class.search.php @@ -21,6 +21,8 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ +require_once INCLUDE_DIR . 'class.role.php'; +require_once INCLUDE_DIR . 'class.list.php'; abstract class SearchBackend { static $id = false; @@ -672,6 +674,19 @@ class SavedSearch extends VerySimpleModel { ))); } + function getName() { + return $this->name; + } + + function getSearchForm() { + if ($state = JsonDataParser::parse($search->config)) { + $form = $search->loadFromState($state); + $form->loadState($state); + return $form; + } + return $this->getForm(); + } + function loadFromState($source=false) { // Pull out 'other' fields from the state so the fields will be // added to the form. The state will be loaded below @@ -787,6 +802,39 @@ class SavedSearch extends VerySimpleModel { } return $core; } + static function getSupportedTicketMatches() { + // User information + $matches = array( + __('Ticket Built-In') => SavedSearch::getExtendedTicketFields(), + ); + foreach (array('ticket'=>'TicketForm', 'user'=>'UserForm', 'organization'=>'OrganizationForm') as $k=>$F) { + $form = $F::objects()->one(); + $fields = &$matches[$form->getLocal('title')]; + foreach ($form->getFields() as $f) { + if (!$f->hasData() || $f->isPresentationOnly()) + continue; + $fields[":$k!".$f->get('id')] = __(ucfirst($k)).' / '.$f->getLocal('label'); + /* TODO: Support matches on list item properties + if (($fi = $f->getImpl()) && $fi->hasSubFields()) { + foreach ($fi->getSubFields() as $p) { + $fields[":$k.".$f->get('id').'.'.$p->get('id')] + = __(ucfirst($k)).' / '.$f->getLocal('label').' / '.$p->getLocal('label'); + } + } + */ + } + } + $fields = &$matches[__('Custom Forms')]; + foreach (DynamicForm::objects()->filter(array('type'=>'G')) as $form) { + foreach ($form->getFields() as $f) { + if (!$f->hasData() || $f->isPresentationOnly()) + continue; + $key = sprintf(':field!%d', $f->get('id'), $f->get('id')); + $fields[$key] = $form->getLocal('title').' / '.$f->getLocal('label'); + } + } + return $matches; + } static function getExtendedTicketFields() { return array( @@ -823,6 +871,36 @@ class SavedSearch extends VerySimpleModel { ); } + static function getSearchableFields($base, $recurse=2, $cache=true) { + static $cache; + + if (!in_array('Searchable', class_implements($base))) + return array(); + + // FIXME: The fields from dynamicFormFields seem to be cached, and + // setting the label is preserved across multiple calls to this + // function. The caching helps with this phenomenon, but a better + // mechanism should be employed + if ($cache && isset($cache[$base])) + return $cache[$base]; + + $fields = $base::getSearchableFields(); + if ($recurse) { + foreach ($base::getMeta('joins') as $path=>$j) { + $fc = $j['fkey'][0]; + if ($fc == $base || $j['list'] || $j['reverse']) + continue; + foreach (static::getSearchableFields($fc, $recurse-1, false) as $path2=>$F) { + $fields["{$path}__{$path2}"] = $F; + $F->set('label', sprintf("%s / %s", $fc, $F->get('label'))); + } + } + } + if ($cache) + $cache[$base] = $fields; + return $fields; + } + static function getSearchField($field, $name) { $baseId = $field->getId() * 20; $pieces = array(); @@ -1125,6 +1203,27 @@ class AssigneeChoiceField extends ChoiceField { return parent::describeSearchMethod($method); } } + + function addToQuery($query, $name=false) { + return $query->values('staff__firstname', 'staff__lastname', 'team__name', 'team_id'); + } + + function from_query($row, $name=false) { + if ($row['staff__firstname']) + return new AgentsName(array('first' => $row['staff__firstname'], 'last' => $row['staff__lastname'])); + if ($row['team_id']) + return Team::getLocalById($row['team_id'], 'name', $row['team__name']); + } + + function display($value) { + return (string) $value; + } +} + +class AgentSelectionField extends ChoiceField { + function getChoices() { + return Staff::getStaffMembers(); + } } class TicketStateChoiceField extends ChoiceField { @@ -1240,3 +1339,10 @@ class TicketStatusChoiceField extends SelectionField { } } } + +interface Searchable { + // Fetch an array of [ orm__path => Field() ] pairs. The field label is + // used when this list is rendered in a dropdown, and the field search + // mechanisms are use to apply query filtering based on the field. + static function getSearchableFields(); +} diff --git a/include/class.staff.php b/include/class.staff.php index e5ed7e77555e5bb72f1376d10de92bc9b579569d..ce922b1af413944042bdaeef33916a83b25f83e3 100644 --- a/include/class.staff.php +++ b/include/class.staff.php @@ -23,7 +23,7 @@ include_once(INCLUDE_DIR.'class.user.php'); include_once(INCLUDE_DIR.'class.auth.php'); class Staff extends VerySimpleModel -implements AuthenticatedUser, EmailContact, TemplateVariable { +implements AuthenticatedUser, EmailContact, TemplateVariable, Searchable { static $meta = array( 'table' => STAFF_TABLE, @@ -127,6 +127,14 @@ implements AuthenticatedUser, EmailContact, TemplateVariable { } } + static function getSearchableFields() { + return array( + 'email' => new TextboxField(array( + 'label' => __('Email Address'), + )), + ); + } + function getHashtable() { $base = $this->ht; unset($base['teams']); diff --git a/include/class.ticket.php b/include/class.ticket.php index 7cc661f9595c6a68e4dc8ee8d47b04b7fd13ddda..0fc4f021df621418c6b236d38e92c1698f528256 100644 --- a/include/class.ticket.php +++ b/include/class.ticket.php @@ -245,7 +245,7 @@ class TicketCData extends VerySimpleModel { } class Ticket extends TicketModel -implements RestrictedAccess, Threadable { +implements RestrictedAccess, Threadable, Searchable { static $meta = array( 'select_related' => array('topic', 'staff', 'user', 'team', 'dept', 'sla', 'thread', @@ -1947,6 +1947,57 @@ implements RestrictedAccess, Threadable { return $base + $extra; } + // Searchable interface + static function getSearchableFields() { + $base = array( + 'number' => new TextboxField(array( + 'label' => __('Ticket Number') + )), + 'ip_address' => new TextboxField(array( + 'label' => __('IP Address'), + 'configuration' => array('validator' => 'ip'), + )), + 'source' => new TicketSourceChoiceField(array( + 'label' => __('Ticket Source'), + )), + 'isoverdue' => new BooleanField(array( + 'label' => __('Overdue'), + )), + 'isanswered' => new BooleanField(array( + 'label' => __('Answered'), + )), + 'duedate' => new DatetimeField(array( + 'label' => __('Due Date'), + )), + 'reopened' => new DatetimeField(array( + 'label' => __('Reopen Date'), + )), + 'closed' => new DatetimeField(array( + 'label' => __('Close Date'), + )), + 'lastupdate' => new DatetimeField(array( + 'label' => __('Last Update'), + )), + 'created' => new DatetimeField(array( + 'label' => __('Create Date'), + )), + 'assignee' => new AssigneeChoiceField(array( + 'label' => __('Assignee'), + )), + ); + $tform = TicketForm::getInstance(); + foreach ($tform->getFields() as $F) { + $fname = $F->get('name') ?: ('field_'.$F->get('id')); + if (!$F->hasData() || $F->isPresentationOnly()) + continue; + if (!$F->isStorable()) + $base[$fname] = $F; + else + $base["cdata__{$fname}"] = $F; + } + return $base; + } + //Replace base variables. function replaceVars($input, $vars = array()) { global $ost; @@ -3674,5 +3725,14 @@ implements RestrictedAccess, Threadable { require STAFFINC_DIR.'templates/tickets-actions.tmpl.php'; } + + static function getLink($id) { + global $thisstaff; + + switch (true) { + case ($thisstaff instanceof Staff): + return ROOT_PATH . sprintf('scp/tickets.php?id=%s', $id); + } + } } ?> diff --git a/include/class.topic.php b/include/class.topic.php index c25ab788a4288beaae3bd1163ecb72c6b2ea183f..ead2e57b6956a36b0db0483c157f86a405ae3f32 100644 --- a/include/class.topic.php +++ b/include/class.topic.php @@ -13,12 +13,12 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ - require_once INCLUDE_DIR . 'class.sequence.php'; require_once INCLUDE_DIR . 'class.filter.php'; +require_once INCLUDE_DIR . 'class.search.php'; class Topic extends VerySimpleModel -implements TemplateVariable { +implements TemplateVariable, Searchable { static $meta = array( 'table' => TOPIC_TABLE, @@ -91,6 +91,14 @@ implements TemplateVariable { ); } + static function getSearchableFields() { + return array( + 'name' => new TextboxField(array( + 'label' => __('Name'), + )), + ); + } + function getId() { return $this->topic_id; } diff --git a/include/class.user.php b/include/class.user.php index 24cae58b9122dc8a5ee3af8ceb9183720e7524d6..a3b98f381b61d1e341ae647ce131fa60fcf28528 100644 --- a/include/class.user.php +++ b/include/class.user.php @@ -16,8 +16,9 @@ **********************************************************************/ require_once INCLUDE_DIR . 'class.orm.php'; require_once INCLUDE_DIR . 'class.util.php'; -require_once INCLUDE_DIR . 'class.organization.php'; require_once INCLUDE_DIR . 'class.variable.php'; +require_once INCLUDE_DIR . 'class.search.php'; +require_once INCLUDE_DIR . 'class.organization.php'; class UserEmailModel extends VerySimpleModel { static $meta = array( @@ -191,7 +192,7 @@ class UserCdata extends VerySimpleModel { } class User extends UserModel -implements TemplateVariable { +implements TemplateVariable, Searchable { var $_entries; var $_forms; @@ -362,6 +363,20 @@ implements TemplateVariable { return $base + $extra; } + static function getSearchableFields() { + $uform = UserForm::getUserForm(); + foreach ($uform->getFields() as $F) { + $fname = $F->get('name') ?: ('field_'.$F->get('id')); + if (!$F->hasData() || $F->isPresentationOnly()) + continue; + if (!$F->isStorable()) + $base[$fname] = $F; + else + $base["cdata__{$fname}"] = $F; + } + return $base; + } + function addDynamicData($data) { return $this->addForm(UserForm::objects()->one(), 1, $data); } @@ -599,6 +614,15 @@ implements TemplateVariable { if ($user = static::lookup($id)) return $user->getName(); } + + static function getLink($id) { + global $thisstaff; + + if (!$id || !$thisstaff) + return false; + + return ROOT_PATH . sprintf('users.php?id=%s', $id); + } } class EmailAddress diff --git a/include/staff/queue.inc.php b/include/staff/queue.inc.php new file mode 100644 index 0000000000000000000000000000000000000000..d322a912554b6bc65e91462834a1609e9f3abd27 --- /dev/null +++ b/include/staff/queue.inc.php @@ -0,0 +1,243 @@ +<?php +// vim: expandtab sw=2 ts=2 sts=2: + +if(!defined('OSTADMININC') || !$thisstaff || !$thisstaff->isAdmin()) die('Access Denied'); + +$info = $qs = array(); + +if ($_REQUEST['a']=='add'){ + if (!$queue) { + $queue = CustomQueue::create(array( + 'flags' => CustomQueue::FLAG_QUEUE, + )); + } + $title=__('Add New Queue'); + $action='create'; + $submit_text=__('Create'); +} +else { + //Editing Department. + $title=__('Manage Custom Queue'); + $action='update'; + $submit_text=__('Save Changes'); + $info['id'] = $queue->getId(); + $qs += array('id' => $queue->getId()); +} +?> + +<form action="queues.php?<?php echo Http::build_query($qs); ?>" method="post" id="save" autocomplete="off"> + <?php csrf_token(); ?> + <input type="hidden" name="do" value="<?php echo $action; ?>"> + <input type="hidden" name="a" value="<?php echo Format::htmlchars($_REQUEST['a']); ?>"> + <input type="hidden" name="id" value="<?php echo $info['id']; ?>"> + + <h2><?php echo __('Ticket Queues'); ?> // <?php echo $title; ?> + <?php if (isset($queue->id)) { ?><small> + — <?php echo $queue->getName(); ?></small> + <?php } ?> + </h2> + + + <ul class="clean tabs"> + <li class="active"><a href="#criteria"><i class="icon-filter"></i> + <?php echo __('Criteria'); ?></a></li> + <li><a href="#columns"><i class="icon-columns"></i> + <?php echo __('Columns'); ?></a></li> + <li><a href="#preview-tab"><i class="icon-eye-open"></i> + <?php echo __('Preview'); ?></a></li> + </ul> + + <div class="tab_content" id="criteria"> + <table class="table"> + <td style="width:60%; vertical-align:top"> + <div><strong><?php echo __('Queue Name'); ?>:</strong></div> + <input type="text" name="name" value="<?php + echo Format::htmlchars($queue->getName()); ?>" + style="width:100%" /> + + <br/> + <br/> + <div><strong><?php echo __("Queue Search Criteria"); ?></strong></div> + <hr/> + <div class="advanced-search"> +<?php + $form = $queue->getSearchForm(); + $search = $queue; + $matches = SavedSearch::getSupportedTicketMatches(); + include STAFFINC_DIR . 'templates/advanced-search-criteria.tmpl.php'; +?> + </div> + </td> + <td style="width:35%; padding-left:40px; vertical-align:top"> + <div><strong><?php echo __("Parent Queue"); ?>:</strong></div> + <select name="parent_id"> + <option value="0">— <?php echo __('Top-Level Queue'); ?> —</option> +<?php foreach (CustomQueue::objects() as $cq) { ?> + <option value="<?php echo $cq->id; ?>"><?php echo $cq->getName(); ?></option> +<?php } ?> + </select> + + <br/> + <br/> + <div><strong><?php echo __("Quick Filter"); ?></strong></div> + <hr/> + <select name="quick-filter"> + <option value=":p:">— <?php echo __('Inherit from parent'); ?> —</option> +<?php foreach (SavedSearch::getSearchableFields('Ticket') as $path=>$f) { ?> + <option value="<?php echo $path; ?>"><?php echo $f->get('label'); ?></option> +<?php } ?> + </select> + <br/> + <br/> + <div><strong><?php echo __("Sort Options"); ?></strong></div> + <hr/> + </td> + </table> + </div> + + <div class="hidden tab_content" id="columns"> + <h2><?php echo __("Manage columns in this queue"); ?></h2> + <p><?php echo __("Add, remove, and customize the content of the columns in this queue using the options below. Click a column header to manage or resize it"); ?></p> + + <div> + <i class="icon-plus-sign"></i> + <select id="add-column" data-next-id="0" onchange="javascript: + var $this = $(this), + selected = $this.find(':selected'), + nextId = $this.data('nextId'), + columns = $('#resizable-columns'); + $.ajax({ + url: 'ajax.php/queue/addColumn', + data: { field: selected.val(), id: nextId }, + dataType: 'json', + success: function(json) { + var div = $('<div></div>') + .addClass('column-header ui-resizable') + .text(json.heading) + .data({id: nextId, colId: 'colconfig-'+nextId, width: json.width}) + .append($('<i>') + .addClass('icon-ellipsis-vertical ui-resizable-handle ui-resizable-handle-e') + ) + .append($('<input />') + .attr({type:'hidden', name:'columns[]'}) + .val(nextId) + ); + config = $('<div></div>') + .addClass('hidden column-configuration') + .attr('id', 'colconfig-' + nextId); + config.append($(json.config)).insertAfter(columns.append(div)); + $this.data('nextId', nextId+1); + } + }); + "> + <option value="">— <?php echo __('Add a column'); ?> —</option> +<?php foreach (SavedSearch::getSearchableFields('Ticket') as $path=>$f) { ?> + <option value="<?php echo $path; ?>"><?php echo $f->get('label'); ?></option> +<?php } ?> + </select> + + <div id="resizable-columns"> +<?php foreach ($queue->getColumns() as $column) { + $colid = $column->getId(); + $maxcolid = max(@$maxcolid ?: 0, $colid); + echo sprintf('<div data-id="%s" data-col-id="colconfig-%s" class="column-header" ' + .'data-width="%s">%s' + .'<i class="icon-ellipsis-vertical ui-resizable-handle ui-resizable-handle-e"></i>' + .'<input type="hidden" name="columns[]" value="%s"/>' + .'</div>', + $colid, $colid, $column->getWidth(), $column->getHeading(), $colid); +} ?> + </div> + <script> + $(function() { + $('#add-column').data('nextId', <?php echo $maxcolid+1; ?>); + var qq = setInterval(function() { + var total = 0, + container = $('#resizable-columns'), + width = container.width(), + w2px = 1.25, + columns = $('.column-header', container); + // Await computation of the <div>'s width + if (width) + clearInterval(qq); + columns.each(function() { + total += $(this).data('width') || 100; + }); + container.data('w2px', w2px); + columns.each(function() { + // FIXME: jQuery will compensate for padding (40px) + $(this).width(w2px * ($(this).data('width') || 100) - 42); + }); + }, 20); + }); + </script> + +<?php foreach ($queue->getColumns() as $column) { + $colid = $column->getId(); + echo sprintf('<div class="hidden column-configuration" id="colconfig-%s">', + $colid); + include STAFFINC_DIR . 'templates/queue-column.tmpl.php'; + echo '</div>'; +} ?> + </div> + + <script> + var aa = setInterval(function() { + var cols = $('#resizable-columns'); + if (cols.length && cols.sortable) + clearInterval(aa); + cols.sortable({ + containment: 'parent' + }); + $('.column-header', cols).resizable({ + handles: {'e' : '.ui-resizable-handle'}, + grid: [ 20, 0 ], + maxHeight: 16, + minHeight: 16, + stop: function(event, ui) { + var w2px = ui.element.parent().data('w2px'), + width = ui.element.width() - 42; + ui.element.data('width', width / w2px); + // TODO: Update WIDTH text box in the data form + } + }); + cols.click('.column-header', function(e) { + var $this = $(event.target); + $this.parent().children().removeClass('active'); + $this.addClass('active'); + $('.column-configuration', $this.closest('.tab_content')).hide(); + $('#'+$this.data('colId')).fadeIn('fast'); + }); + }, 20); + </script> + </div> + + <div class="hidden tab_content" id="preview-tab"> + + <div id="preview"> + </div> + + <script> + $(function() { + $('#preview-tab').on('afterShow', function() { + $.ajax({ + url: 'ajax.php/queue/preview', + type: 'POST', + data: $('#save').serializeArray(), + success: function(html) { + $('#preview').html(html); + } + }); + }); + }); + </script> + + </div> + + <p style="text-align:center;"> + <input type="submit" name="submit" value="<?php echo $submit_text; ?>"> + <input type="reset" name="reset" value="<?php echo __('Reset');?>"> + <input type="button" name="cancel" value="<?php echo __('Cancel');?>" onclick="window.history.go(-1);"> + </p> + +</form> diff --git a/include/staff/queues-ticket.inc.php b/include/staff/queues-ticket.inc.php new file mode 100644 index 0000000000000000000000000000000000000000..170edafed6e22445c3a6b2ce74edea5103468272 --- /dev/null +++ b/include/staff/queues-ticket.inc.php @@ -0,0 +1,54 @@ + +<form action="queues.php?t=tickets" method="POST" name="keys"> + <div class="sticky bar opaque"> + <div class="content"> + <div class="pull-right"> + <a href="queues.php?t=tickets&a=add" class="green button action-button"><i class="icon-plus-sign"></i> <?php echo __('Add New Queue');?></a> + <span class="action-button" data-dropdown="#action-dropdown-more"> + <i class="icon-caret-down pull-right"></i> + <span ><i class="icon-cog"></i> <?php echo __('More');?></span> + </span> + <div id="action-dropdown-more" class="action-dropdown anchor-right"> + <ul id="actions"> + <li> + <a class="confirm" data-name="enable" href="queues.php?t=tickets&a=enable"> + <i class="icon-ok-sign icon-fixed-width"></i> + <?php echo __( 'Enable'); ?> + </a> + </li> + <li> + <a class="confirm" data-name="disable" href="queues.php?t=tickets&a=disable"> + <i class="icon-ban-circle icon-fixed-width"></i> + <?php echo __( 'Disable'); ?> + </a> + </li> + <li class="danger"> + <a class="confirm" data-name="delete" href="queues.php?t=tickets&a=delete#queues"> + <i class="icon-trash icon-fixed-width"></i> + <?php echo __( 'Delete'); ?> + </a> + </li> + </ul> + </div> + </div> + <h3><?php echo __('Ticket Queues');?></h3> + </div> + </div> + <div class="clear"></div> + <?php csrf_token(); ?> + <input type="hidden" name="do" value="mass_process" > +<input type="hidden" id="action" name="a" value="" > + <table class="list" border="0" cellspacing="1" cellpadding="0" width="940"> + <thead> + <tr> + <th width="4%"> </th> + <th width="46%"><a <?php echo $key_sort; ?> href="queues.php?t=tickets&<?php echo $qstr; ?>&sort=name#queues"><?php echo __('Name');?></a></th> + <th width="12%"><a <?php echo $ip_sort; ?> href="queues.php?t=tickets&<?php echo $qstr; ?>&sort=creator#queues"><?php echo __('Creator');?></a></th> + <th width="8%"><a <?php echo $status_sort; ?> href="queues.php?t=tickets&<?php echo $qstr; ?>&sort=status#queues"><?php echo __('Status');?></a></th> + <th width="10%" nowrap><a <?php echo $date_sort; ?>href="queues.php?t=tickets&<?php echo $qstr; ?>&sort=date#queues"><?php echo __('Created');?></a></th> + </tr> + </thead> + <tbody> + </tbody> +</table> +</form> diff --git a/include/staff/settings-tickets.inc.php b/include/staff/settings-tickets.inc.php index 8ec82503174c174509d51d3a923240dee7b4b1a2..454b53f64f331f4597120a61824d62dfe65b978b 100644 --- a/include/staff/settings-tickets.inc.php +++ b/include/staff/settings-tickets.inc.php @@ -15,6 +15,8 @@ if(!($maxfileuploads=ini_get('max_file_uploads'))) <?php echo __('Autoresponder'); ?></a></li> <li><a href="#alerts"><i class="icon-bell-alt"></i> <?php echo __('Alerts and Notices'); ?></a></li> + <li><a href="#queues"><i class="icon-table"></i> + <?php echo __('Queues'); ?></a></li> </ul> <div class="tab_content" id="settings"> <table class="form_table settings_table" width="940" border="0" cellspacing="0" cellpadding="2"> @@ -239,6 +241,10 @@ if(!($maxfileuploads=ini_get('max_file_uploads'))) <?php include STAFFINC_DIR . 'settings-alerts.inc.php'; ?> </div> +<div class="hidden tab_content" id="queues"> + <?php include STAFFINC_DIR . 'queues-ticket.inc.php'; ?> +</div> + <p style="text-align:center;"> <input class="button" type="submit" name="submit" value="<?php echo __('Save Changes');?>"> <input class="button" type="reset" name="reset" value="<?php echo __('Reset Changes');?>"> diff --git a/include/staff/templates/advanced-search-criteria.tmpl.php b/include/staff/templates/advanced-search-criteria.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..ea343245a463f9e0180db2e72dcbd3d458753575 --- /dev/null +++ b/include/staff/templates/advanced-search-criteria.tmpl.php @@ -0,0 +1,108 @@ +<?php +foreach ($form->errors(true) ?: array() as $message) { + ?><div class="error-banner"><?php echo $message;?></div><?php +} + +$info = $search->getSearchFields($form); +foreach (array_keys($info) as $F) { + ?><input type="hidden" name="fields[]" value="<?php echo $F; ?>"/><?php +} +$errors = !!$form->errors(); +$inbody = false; +$first_field = true; +foreach ($form->getFields() as $name=>$field) { + @list($name, $sub) = explode('+', $field->get('name'), 2); + if ($sub === 'search') { + if (!$first_field) { + echo '</div></div>'; + } + echo '<div class="adv-search-field-container">'; + $inbody = false; + $first_field = false; + } + elseif (!$first_field && !$inbody) { + echo sprintf('<div class="adv-search-field-body %s">', + !$errors && isset($info[$name]) && $info[$name]['active'] ? 'hidden' : ''); + $inbody = true; + } +?> + <fieldset id="field<?php echo $field->getWidget()->id; ?>" <?php + $class = array(); + if (!$field->isVisible()) + $class[] = "hidden"; + if ($sub === 'method') + $class[] = "adv-search-method"; + elseif ($sub === 'search') + $class[] = "adv-search-field"; + elseif ($field->get('__searchval__')) + $class[] = "adv-search-val"; + if ($class) + echo 'class="'.implode(' ', $class).'"'; + ?>> + <?php echo $field->render(); ?> + <?php if (!$errors && $sub === 'search' && isset($info[$name]) && $info[$name]['active']) { ?> + <span style="padding-left: 5px"> + <a href="#" data-name="<?php echo Format::htmlchars($name); ?>" onclick="javascript: + var $this = $(this), + name = $this.data('name'), + expanded = $this.data('expanded') || false; + $this.closest('.adv-search-field-container').find('.adv-search-field-body').slideDown('fast'); + $this.find('span.faded').hide(); + $this.find('i').removeClass('icon-caret-right').addClass('icon-caret-down'); + return false; +"><i class="icon-caret-right"></i> + <span class="faded"><?php echo $search->describeField($info[$name]); ?></span> + </a> + </span> + <?php } ?> + <?php foreach ($field->errors() as $E) { + ?><div class="error"><?php echo $E; ?></div><?php + } ?> + </fieldset> + <?php if ($name[0] == ':' && substr($name, -7) == '+search') { + list($N,) = explode('+', $name, 2); +?> + <input type="hidden" name="fields[]" value="<?php echo $N; ?>"/> + <?php } +} +if (!$first_field) + echo '</div></div>'; +?> +<div id="extra-fields"></div> +<hr/> +<i class="icon-plus-sign"></i> +<select id="search-add-new-field" name="new-field" style="max-width: 300px;"> + <option value="">— <?php echo __('Add Other Field'); ?> —</option> +<?php +if (is_array($matches)) { +foreach ($matches as $name => $fields) { ?> + <optgroup label="<?php echo $name; ?>"> +<?php + foreach ($fields as $id => $desc) { ?> + <option value="<?php echo $id; ?>" <?php + if (isset($state[$id])) echo 'disabled="disabled"'; + ?>><?php echo ($desc instanceof FormField ? $desc->getLocal('label') : $desc); ?></option> +<?php } ?> + </optgroup> +<?php } +} ?> +</select> +<script> +$(function() { + $('#search-add-new-field').on('change', function() { + var that=this; + $.ajax({ + url: 'ajax.php/tickets/search/field/'+$(this).val(), + type: 'get', + dataType: 'json', + success: function(json) { + if (!json.success) + return false; + ff_uid = json.ff_uid; + $(that).find(':selected').prop('disabled', true); + $('#extra-fields').append($(json.html)); + } + }); + }); +}); +</script> diff --git a/include/staff/templates/advanced-search.tmpl.php b/include/staff/templates/advanced-search.tmpl.php index cc14fa18df075e28b0369cb40474791cbd5a5cde..201cb66b0af583203453613a784ad76059bf8ae6 100644 --- a/include/staff/templates/advanced-search.tmpl.php +++ b/include/staff/templates/advanced-search.tmpl.php @@ -1,4 +1,4 @@ -<div id="advanced-search"> +<div id="advanced-search" class="advanced-search"> <h3 class="drag-handle"><?php echo __('Advanced Ticket Search');?></h3> <a class="close" href=""><i class="icon-remove-circle"></i></a> <hr/> @@ -6,93 +6,7 @@ <div class="row"> <div class="span6"> <input type="hidden" name="a" value="search"> -<?php -foreach ($form->errors(true) ?: array() as $message) { - ?><div class="error-banner"><?php echo $message;?></div><?php -} - -$info = $search->getSearchFields($form); -foreach (array_keys($info) as $F) { - ?><input type="hidden" name="fields[]" value="<?php echo $F; ?>"/><?php -} -$errors = !!$form->errors(); -$inbody = false; -$first_field = true; -foreach ($form->getFields() as $name=>$field) { - @list($name, $sub) = explode('+', $field->get('name'), 2); - if ($sub === 'search') { - if (!$first_field) { - echo '</div></div>'; - } - echo '<div class="adv-search-field-container">'; - $inbody = false; - $first_field = false; - } - elseif (!$first_field && !$inbody) { - echo sprintf('<div class="adv-search-field-body %s">', - !$errors && isset($info[$name]) && $info[$name]['active'] ? 'hidden' : ''); - $inbody = true; - } -?> - <fieldset id="field<?php echo $field->getWidget()->id; ?>" <?php - $class = array(); - if (!$field->isVisible()) - $class[] = "hidden"; - if ($sub === 'method') - $class[] = "adv-search-method"; - elseif ($sub === 'search') - $class[] = "adv-search-field"; - elseif ($field->get('__searchval__')) - $class[] = "adv-search-val"; - if ($class) - echo 'class="'.implode(' ', $class).'"'; - ?>> - <?php echo $field->render(); ?> - <?php if (!$errors && $sub === 'search' && isset($info[$name]) && $info[$name]['active']) { ?> - <span style="padding-left: 5px"> - <a href="#" data-name="<?php echo Format::htmlchars($name); ?>" onclick="javascript: - var $this = $(this), - name = $this.data('name'), - expanded = $this.data('expanded') || false; - $this.closest('.adv-search-field-container').find('.adv-search-field-body').slideDown('fast'); - $this.find('span.faded').hide(); - $this.find('i').removeClass('icon-caret-right').addClass('icon-caret-down'); - return false; -"><i class="icon-caret-right"></i> - <span class="faded"><?php echo $search->describeField($info[$name]); ?></span> - </a> - </span> - <?php } ?> - <?php foreach ($field->errors() as $E) { - ?><div class="error"><?php echo $E; ?></div><?php - } ?> - </fieldset> - <?php if ($name[0] == ':' && substr($name, -7) == '+search') { - list($N,) = explode('+', $name, 2); -?> - <input type="hidden" name="fields[]" value="<?php echo $N; ?>"/> - <?php } -} -if (!$first_field) - echo '</div></div>'; -?> -<div id="extra-fields"></div> -<hr/> -<select id="search-add-new-field" name="new-field" style="max-width: 300px;"> - <option value="">— <?php echo __('Add Other Field'); ?> —</option> -<?php -foreach ($matches as $name => $fields) { ?> - <optgroup label="<?php echo $name; ?>"> -<?php - foreach ($fields as $id => $desc) { ?> - <option value="<?php echo $id; ?>" <?php - if (isset($state[$id])) echo 'disabled="disabled"'; - ?>><?php echo ($desc instanceof FormField ? $desc->getLocal('label') : $desc); ?></option> -<?php } ?> - </optgroup> -<?php } ?> -</select> - + <?php include STAFFINC_DIR . 'templates/advanced-search-criteria.tmpl.php'; ?> </div> <div class="span6" style="border-left:1px solid #888;position:relative;padding-bottom:26px;"> <div style="margin-bottom: 0.5em;"><b style="font-size: 110%;"><?php echo __('Saved Searches'); ?></b></div> @@ -208,21 +122,5 @@ $(function() { return false; }); }, 200); - - $('#search-add-new-field').on('change', function() { - var that=this; - $.ajax({ - url: 'ajax.php/tickets/search/field/'+$(this).val(), - type: 'get', - dataType: 'json', - success: function(json) { - if (!json.success) - return false; - ff_uid = json.ff_uid; - $(that).find(':selected').prop('disabled', true); - $('#extra-fields').append($(json.html)); - } - }); - }); }); </script> diff --git a/include/staff/templates/queue-column-condition-prop.tmpl.php b/include/staff/templates/queue-column-condition-prop.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..f29f5947d99b6a57f73df9aa96648b261cb0b0af --- /dev/null +++ b/include/staff/templates/queue-column-condition-prop.tmpl.php @@ -0,0 +1,24 @@ +<?php +/** + * Calling conventions + * + * $name - condition field name, like 'thread__lastmessage' + * $prop - CSS property name from QueueColumnConditionProperty::$properties + * $v - value for the property + */ +?> +<div class="condition-property"> + <div class="pull-right"> + <a href="#" onclick="javascript:$(this).closest('.condition-property').remove()" + ><i class="icon-trash"></i></a> + </div> + <div><?php echo mb_convert_case($prop, MB_CASE_TITLE); ?></div> +<?php + $F = QueueColumnConditionProperty::getField($prop); + $F->set('name', "prop-{$name}-{$prop}"); + $F->value = $v; + $form = new SimpleForm(array($F), $_POST); + echo $F->render(); + echo $form->getMedia(); +?> +</div> diff --git a/include/staff/templates/queue-column-condition.tmpl.php b/include/staff/templates/queue-column-condition.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..d445f4f4d1ac567018b55fc414b876673c5a605f --- /dev/null +++ b/include/staff/templates/queue-column-condition.tmpl.php @@ -0,0 +1,74 @@ +<?php +// Calling convention: +// +// $field - field for the condition (Ticket / Last Update) +// $properties - currently-configured properties for the condition +// $condition - <QueueColumnCondition> instance for this condition +?> +<div class="condition"> + <div class="pull-right"> + <a href="#" onclick="javascript: $(this).closest('.condition').remove(); + "><i class="icon-trash"></i></a> + </div> + <?php echo $field->get('label'); ?> + <div class="advanced-search"> +<?php +$name = $field->get('name'); +$parts = SavedSearch::getSearchField($field, $name); +// Drop the search checkbox field +unset($parts["{$name}+search"]); +foreach ($parts as $name=>$F) { + if (substr($name, -7) == '+method') + // XXX: Hack + unset($F->ht['visibility']); +} +$form = new SimpleForm($parts); +foreach ($form->getFields() as $F) { ?> + <fieldset id="field<?php echo $F->getWidget()->id; + ?>" <?php + $class = array(); + @list($name, $sub) = explode('+', $F->get('name'), 2); + if (!$F->isVisible()) $class[] = "hidden"; + if ($sub === 'method') + $class[] = "adv-search-method"; + elseif ($F->get('__searchval__')) + $class[] = "adv-search-val"; + if ($class) + echo 'class="'.implode(' ', $class).'"'; + ?>> + <?php echo $F->render(); ?> + <?php foreach ($F->errors() as $E) { + ?><div class="error"><?php echo $E; ?></div><?php + } ?> + </fieldset> +<?php } ?> + + <div class="properties" style="margin-left: 25px; margin-top: 10px"> +<?php foreach ($condition->getProperties() as $prop=>$v) { + include 'queue-column-condition-prop.tmpl.php'; +} ?> + <div style="margin-top: 10px"> + <i class="icon-plus-sign"></i> + <select onchange="javascript: + var $this = $(this), + selected = $this.find(':selected'), + container = $this.closest('.properties'); + $.ajax({ + url: 'ajax.php/queue/condition/addProperty', + data: { prop: selected.val() }, + dataType: 'html', + success: function(html) { + $(html).insertBefore(container); + selected.prop('disabled', true); + } + }); + "> + <option>— <?php echo __('Add a property'); ?> —</option> +<?php foreach (array_keys(QueueColumnConditionProperty::$properties) as $p) { + echo sprintf('<option value="%s">%s</option>', $p, mb_convert_case($p, MB_CASE_TITLE)); +} ?> + </select> + </div> + </div> + </div> +</div> diff --git a/include/staff/templates/queue-column.tmpl.php b/include/staff/templates/queue-column.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..a3b4b56dea408d0faecc7cfcc997e064791e0f46 --- /dev/null +++ b/include/staff/templates/queue-column.tmpl.php @@ -0,0 +1,123 @@ +<?php +/** + * Calling conventions + * + * $column - <QueueColumn> instance for this column + */ +$colid = $column->getId(); +$data_form = $column->getDataConfigForm($_POST); +?> +<ul class="alt tabs"> + <li class="active"><a href="#<?php echo $colid; ?>-data"><?php echo __('Data'); ?></a></li> + <li><a href="#<?php echo $colid; ?>-decorations"><?php echo __('Decorations'); ?></a></li> + <li><a href="#<?php echo $colid; ?>-conditions"><?php echo __('Conditions'); ?></a></li> +</ul> + +<div class="tab_content" id="<?php echo $colid; ?>-data"> +<?php + print $data_form->asTable(); +?> +</div> + +<div class="hidden tab_content" id="<?php echo $colid; ?>-decorations" style="max-width: 400px"> + <div class="empty placeholder" style="margin-left: 20px"> + <em><?php echo __('No decorations for this field'); ?></em> + </div> + <div style="margin: 20px;"> + <div class="decoration clear template hidden"> + <input data-field="input" data-name="decorations[]" value="" type="hidden" /> + <i data-field="icon"></i> + <span data-field="name"></span> + <div class="pull-right"> + <select data-field="position"> +<?php foreach (QueueDecoration::getPositions() as $key=>$desc) { + echo sprintf('<option value="%s">%s</option>', $key, Format::htmlchars($desc)); +} ?> + </select> + <a href="#" data-field="delete" title="<?php echo __('Delete'); ?>" + onclick="javascript: + $(this).closest('.decoration').remove(); + return false;"><i class="icon-trash"></i></a> + </div> + </div> + + <div style="margin-top: 20px"> + <i class="icon-plus-sign"></i> + <select class="add-decoration"> + <option>— <?php echo __("Add a decoration"); ?> —</option> +<?php foreach (CustomQueue::getDecorations('Ticket') as $class) { + echo sprintf('<option data-icon="%s" value="%s">%s</option>', + $class::$icon, $class, $class::getDescription()); + } ?> + </select> + </div> + + <script> + $(function() { + var addDecoration = function(type, icon, pos) { + var template = $('.decoration.template', '#<?php echo $colid; ?>-decorations'), + clone = template.clone().show().removeClass('template').insertBefore(template), + input = clone.find('[data-field=input]'), + name = clone.find('[data-field=name]'), + i = clone.find('[data-field=icon]'), + position = clone.find('[data-field=position]'); + input.attr('name', input.data('name')); + i.addClass('icon-fixed-width icon-' + icon); + name.text(type); + if (pos) position.val(pos); + template.parent().find('.empty').hide(); + }; + $('select.add-decoration', '#<?php echo $colid; ?>-decorations').change(function() { + var selected = $(this).find(':selected'); + addDecoration(selected.text(), selected.data('icon')); + selected.prop('disabled', true); + }); + $('#<?php echo $colid; ?>-decorations').click('a[data-field=delete]', + function() { + var tab = $('#<?php echo $colid; ?>-decorations'); + if ($('.decoration', tab).length === 0) + tab.find('.empty').show(); + }); + <?php foreach ($column->getDecorations() as $d) { + echo sprintf('addDecoration(%s, %s, %s);', + JsonDataEncoder::encode($d::getDescription()), + JsonDataEncoder::encode($d::getIcon()), + JsonDataEncoder::encode($d->getPosition()) + ); + } ?> + }); + </script> + </div> +</div> + +<div class="hidden tab_content" id="<?php echo $colid; ?>-conditions"> + <div style="margin: 0 20px"><?php echo __("Conditions are used to change the view of the data in a row based on some conditions of the data. For instance, a column might be shown bold if some condition is met."); + ?></div> + <div class="conditions" style="margin: 20px; max-width: 400px"> +<?php foreach ($column->getConditions() as $condition) { + include STAFFINC_DIR . 'templates/queue-column-condition.tmpl.php'; +} ?> + <div style="margin-top: 20px"> + <i class="icon-plus-sign"></i> + <select id="add-condition" onchange="javascript: + var $this = $(this), + container = $this.closest('div'); + $.ajax({ + url: 'ajax.php/queue/condition/add', + data: { field: $(this).find(':selected').val() }, + dataType: 'html', + success: function(html) { + $(html).insertBefore(container); + } + }); + "> + <option>— <?php echo __("Add a condition"); ?> —</option> +<?php + foreach (SavedSearch::getSearchableFields('Ticket') as $path=>$f) { + echo sprintf('<option value="%s">%s</option>', $path, Format::htmlchars($f->get('label'))); + } +?> + </select> + </div> + </div> +</div> diff --git a/include/staff/templates/queue-tickets.tmpl.php b/include/staff/templates/queue-tickets.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..32d664d906b4fa0d6076b0dd888275ad8de3af8e --- /dev/null +++ b/include/staff/templates/queue-tickets.tmpl.php @@ -0,0 +1,40 @@ +<?php +// Calling convention (assumed global scope): +// $tickets - <QuerySet> with all columns and annotations necessary to +// render the full page +// $count - <int> number of records matching the search / filter part of the +// query + +$page = ($_GET['p'] && is_numeric($_GET['p']))?$_GET['p']:1; +$pageNav = new Pagenate($count, $page, PAGE_LIMIT); +$pageNav->setURL('tickets.php', $args); +$tickets = $pageNav->paginate($tickets); + +// Identify columns of output +$columns = $queue->getColumns(); + +?> +<table class="list" border="0" cellspacing="1" cellpadding="2" width="940"> + <thead> + <tr> +<?php +foreach ($columns as $C) { + echo sprintf('<th width="%s">%s</th>', $C->getWidth(), + Format::htmlchars($C->getHeading())); +} ?> + </tr> + </thead> + <tbody> +<?php +foreach ($tickets as $T) { + echo '<tr>'; + foreach ($columns as $C) { + echo "<td>"; + echo $C->render($T); + echo "</td>"; + } + echo '</tr>'; +} +?> + </tbody> +</table> diff --git a/include/staff/tickets.inc.php b/include/staff/tickets.inc.php index c786e28468ab527ec60b88aaf24040d65f1601f9..15881dce53da050a89bc66d7d40cea206d7d5452 100644 --- a/include/staff/tickets.inc.php +++ b/include/staff/tickets.inc.php @@ -229,7 +229,7 @@ if (!$view_all_tickets) { if (!$thisstaff->showAssignedOnly() && ($depts=$thisstaff->getDepts())) $visibility->add(array('dept_id__in' => $depts)); - $tickets->filter(Q::any($visibility)); + $tickets->filter($visibility); } // TODO :: Apply requested quick filter diff --git a/scp/ajax.php b/scp/ajax.php index de27157dc74093cf638db8a5a3e0550384e00857..1351b9fbc58c606076286c55bbc71cd8127be9a9 100644 --- a/scp/ajax.php +++ b/scp/ajax.php @@ -253,6 +253,12 @@ $dispatcher = patterns('', url('^/reset-permissions', 'resetPermissions'), url('^/change-department', 'changeDepartment'), url('^/(?P<id>\d+)/avatar/change', 'setAvatar') + )), + url('^/queue/', patterns('ajax.search.php:SearchAjaxAPI', + url('^(?P<id>\d+/)?preview$', 'previewQueue'), + url_get('^addColumn$', 'addColumn'), + url_get('^condition/add$', 'addCondition'), + url_get('^condition/addProperty$', 'addConditionProperty') )) ); diff --git a/scp/css/scp.css b/scp/css/scp.css index 86632ba2766ff353f499c2c13b1d4b8b60d68b16..8e5e10b70b92025fa6b9fb27c9fdf92aaad77e42 100644 --- a/scp/css/scp.css +++ b/scp/css/scp.css @@ -1709,7 +1709,7 @@ time.faq { width:100%; } -.dialog fieldset { +fieldset { margin:0; padding:0 0; border:none; @@ -1771,12 +1771,12 @@ time.faq { margin-top: 5px !important; } -#advanced-search fieldset { +.advanced-search fieldset { margin-top: 3px; position: relative; } -#advanced-search .adv-search-method:before, -#advanced-search .adv-search-val:before { +.advanced-search .adv-search-method:before, +.advanced-search .adv-search-val:before { content: ""; border-left: 2px dotted #ccc; border-bottom: 2px dotted #ccc; @@ -1787,10 +1787,10 @@ time.faq { position: absolute; left: -16px; } -#advanced-search .adv-search-method { +.advanced-search .adv-search-method { margin-left: 24px; } -#advanced-search .adv-search-val { +.advanced-search .adv-search-val { margin-left: 45px; } @@ -2876,7 +2876,9 @@ table.grid.form caption { .grid.form .field textarea, .grid.form .field select { width: 100%; + max-width: 100%; display: block; + box-sizing: border-box; } .grid.form .field > label { display: block; @@ -2921,6 +2923,49 @@ a.attachment { margin-bottom: 0.3em; } +#resizable-columns { + margin: 10px 0; + position: relative; +} +#resizable-columns .column-header:hover { + cursor: pointer; +} +#resizable-columns .column-header { + display: inline-block; + padding: 5px 20px; + background-color: #ddd; + margin: 0 1px; + position: relative; + text-align: center; + box-sizing: border-box; +} +#resizable-columns .column-header.ui-resizable:not(.active) { + opacity: 0.4; +} +#resizable-columns .column-header.ui-resizable.active { + background-color: #cfe6ff; +} + +.ui-resizable-handle { + cursor: pointer; + cursor: ew-resize; + cursor: col-resize; + display: inline-block; + vertical-align: bottom; + position: absolute; + right: 5px; + color: #777; +} +.decoration + .decoration { + margin-top: 10px; +} +.advanced-search .condition-property { + margin: 7px 0 7px 25px; +} +.conditions .condition + .condition { + margin-top: 10px; +} + /* FIXME: Drop this with select2 4.0.1 * Fixes a rendering issue on Safari */ diff --git a/scp/css/spectrum.css b/scp/css/spectrum.css new file mode 100644 index 0000000000000000000000000000000000000000..ecf6fe482c01ede9de6e83edf4aaa6dbb06ce86c --- /dev/null +++ b/scp/css/spectrum.css @@ -0,0 +1,507 @@ +/*** +Spectrum Colorpicker v1.7.1 +https://github.com/bgrins/spectrum +Author: Brian Grinstead +License: MIT +***/ + +.sp-container { + position:absolute; + top:0; + left:0; + display:inline-block; + *display: inline; + *zoom: 1; + /* https://github.com/bgrins/spectrum/issues/40 */ + z-index: 9999994; + overflow: hidden; +} +.sp-container.sp-flat { + position: relative; +} + +/* Fix for * { box-sizing: border-box; } */ +.sp-container, +.sp-container * { + -webkit-box-sizing: content-box; + -moz-box-sizing: content-box; + box-sizing: content-box; +} + +/* http://ansciath.tumblr.com/post/7347495869/css-aspect-ratio */ +.sp-top { + position:relative; + width: 100%; + display:inline-block; +} +.sp-top-inner { + position:absolute; + top:0; + left:0; + bottom:0; + right:0; +} +.sp-color { + position: absolute; + top:0; + left:0; + bottom:0; + right:20%; +} +.sp-hue { + position: absolute; + top:0; + right:0; + bottom:0; + left:84%; + height: 100%; +} + +.sp-clear-enabled .sp-hue { + top:33px; + height: 77.5%; +} + +.sp-fill { + padding-top: 80%; +} +.sp-sat, .sp-val { + position: absolute; + top:0; + left:0; + right:0; + bottom:0; +} + +.sp-alpha-enabled .sp-top { + margin-bottom: 18px; +} +.sp-alpha-enabled .sp-alpha { + display: block; +} +.sp-alpha-handle { + position:absolute; + top:-4px; + bottom: -4px; + width: 6px; + left: 50%; + cursor: pointer; + border: 1px solid black; + background: white; + opacity: .8; +} +.sp-alpha { + display: none; + position: absolute; + bottom: -14px; + right: 0; + left: 0; + height: 8px; +} +.sp-alpha-inner { + border: solid 1px #333; +} + +.sp-clear { + display: none; +} + +.sp-clear.sp-clear-display { + background-position: center; +} + +.sp-clear-enabled .sp-clear { + display: block; + position:absolute; + top:0px; + right:0; + bottom:0; + left:84%; + height: 28px; +} + +/* Don't allow text selection */ +.sp-container, .sp-replacer, .sp-preview, .sp-dragger, .sp-slider, .sp-alpha, .sp-clear, .sp-alpha-handle, .sp-container.sp-dragging .sp-input, .sp-container button { + -webkit-user-select:none; + -moz-user-select: -moz-none; + -o-user-select:none; + user-select: none; +} + +.sp-container.sp-input-disabled .sp-input-container { + display: none; +} +.sp-container.sp-buttons-disabled .sp-button-container { + display: none; +} +.sp-container.sp-palette-buttons-disabled .sp-palette-button-container { + display: none; +} +.sp-palette-only .sp-picker-container { + display: none; +} +.sp-palette-disabled .sp-palette-container { + display: none; +} + +.sp-initial-disabled .sp-initial { + display: none; +} + + +/* Gradients for hue, saturation and value instead of images. Not pretty... but it works */ +.sp-sat { + background-image: -webkit-gradient(linear, 0 0, 100% 0, from(#FFF), to(rgba(204, 154, 129, 0))); + background-image: -webkit-linear-gradient(left, #FFF, rgba(204, 154, 129, 0)); + background-image: -moz-linear-gradient(left, #fff, rgba(204, 154, 129, 0)); + background-image: -o-linear-gradient(left, #fff, rgba(204, 154, 129, 0)); + background-image: -ms-linear-gradient(left, #fff, rgba(204, 154, 129, 0)); + background-image: linear-gradient(to right, #fff, rgba(204, 154, 129, 0)); + -ms-filter: "progid:DXImageTransform.Microsoft.gradient(GradientType = 1, startColorstr=#FFFFFFFF, endColorstr=#00CC9A81)"; + filter : progid:DXImageTransform.Microsoft.gradient(GradientType = 1, startColorstr='#FFFFFFFF', endColorstr='#00CC9A81'); +} +.sp-val { + background-image: -webkit-gradient(linear, 0 100%, 0 0, from(#000000), to(rgba(204, 154, 129, 0))); + background-image: -webkit-linear-gradient(bottom, #000000, rgba(204, 154, 129, 0)); + background-image: -moz-linear-gradient(bottom, #000, rgba(204, 154, 129, 0)); + background-image: -o-linear-gradient(bottom, #000, rgba(204, 154, 129, 0)); + background-image: -ms-linear-gradient(bottom, #000, rgba(204, 154, 129, 0)); + background-image: linear-gradient(to top, #000, rgba(204, 154, 129, 0)); + -ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#00CC9A81, endColorstr=#FF000000)"; + filter : progid:DXImageTransform.Microsoft.gradient(startColorstr='#00CC9A81', endColorstr='#FF000000'); +} + +.sp-hue { + background: -moz-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); + background: -ms-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); + background: -o-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); + background: -webkit-gradient(linear, left top, left bottom, from(#ff0000), color-stop(0.17, #ffff00), color-stop(0.33, #00ff00), color-stop(0.5, #00ffff), color-stop(0.67, #0000ff), color-stop(0.83, #ff00ff), to(#ff0000)); + background: -webkit-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); + background: linear-gradient(to bottom, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); +} + +/* IE filters do not support multiple color stops. + Generate 6 divs, line them up, and do two color gradients for each. + Yes, really. + */ +.sp-1 { + height:17%; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0000', endColorstr='#ffff00'); +} +.sp-2 { + height:16%; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffff00', endColorstr='#00ff00'); +} +.sp-3 { + height:17%; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ff00', endColorstr='#00ffff'); +} +.sp-4 { + height:17%; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ffff', endColorstr='#0000ff'); +} +.sp-5 { + height:16%; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#0000ff', endColorstr='#ff00ff'); +} +.sp-6 { + height:17%; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff00ff', endColorstr='#ff0000'); +} + +.sp-hidden { + display: none !important; +} + +/* Clearfix hack */ +.sp-cf:before, .sp-cf:after { content: ""; display: table; } +.sp-cf:after { clear: both; } +.sp-cf { *zoom: 1; } + +/* Mobile devices, make hue slider bigger so it is easier to slide */ +@media (max-device-width: 480px) { + .sp-color { right: 40%; } + .sp-hue { left: 63%; } + .sp-fill { padding-top: 60%; } +} +.sp-dragger { + border-radius: 5px; + height: 5px; + width: 5px; + border: 1px solid #fff; + background: #000; + cursor: pointer; + position:absolute; + top:0; + left: 0; +} +.sp-slider { + position: absolute; + top:0; + cursor:pointer; + height: 3px; + left: -1px; + right: -1px; + border: 1px solid #000; + background: white; + opacity: .8; +} + +/* +Theme authors: +Here are the basic themeable display options (colors, fonts, global widths). +See http://bgrins.github.io/spectrum/themes/ for instructions. +*/ + +.sp-container { + border-radius: 0; + background-color: #ECECEC; + border: solid 1px #f0c49B; + padding: 0; +} +.sp-container, .sp-container button, .sp-container input, .sp-color, .sp-hue, .sp-clear { + font: normal 12px "Lucida Grande", "Lucida Sans Unicode", "Lucida Sans", Geneva, Verdana, sans-serif; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; +} +.sp-top { + margin-bottom: 3px; +} +.sp-color, .sp-hue, .sp-clear { + border: solid 1px #666; +} + +/* Input */ +.sp-input-container { + float:right; + width: 100px; + margin-bottom: 4px; +} +.sp-initial-disabled .sp-input-container { + width: 100%; +} +.sp-input { + font-size: 12px !important; + border: 1px inset; + padding: 4px 5px; + margin: 0; + width: 100%; + background:transparent; + border-radius: 3px; + color: #222; +} +.sp-input:focus { + border: 1px solid orange; +} +.sp-input.sp-validation-error { + border: 1px solid red; + background: #fdd; +} +.sp-picker-container , .sp-palette-container { + float:left; + position: relative; + padding: 10px; + padding-bottom: 300px; + margin-bottom: -290px; +} +.sp-picker-container { + width: 172px; + border-left: solid 1px #fff; +} + +/* Palettes */ +.sp-palette-container { + border-right: solid 1px #ccc; +} + +.sp-palette-only .sp-palette-container { + border: 0; +} + +.sp-palette .sp-thumb-el { + display: block; + position:relative; + float:left; + width: 24px; + height: 15px; + margin: 3px; + cursor: pointer; + border:solid 2px transparent; +} +.sp-palette .sp-thumb-el:hover, .sp-palette .sp-thumb-el.sp-thumb-active { + border-color: orange; +} +.sp-thumb-el { + position:relative; +} + +/* Initial */ +.sp-initial { + float: left; + border: solid 1px #333; +} +.sp-initial span { + width: 30px; + height: 25px; + border:none; + display:block; + float:left; + margin:0; +} + +.sp-initial .sp-clear-display { + background-position: center; +} + +/* Buttons */ +.sp-palette-button-container, +.sp-button-container { + float: right; +} + +/* Replacer (the little preview div that shows up instead of the <input>) */ +.sp-replacer { + margin:0; + overflow:hidden; + cursor:pointer; + padding: 4px; + display:inline-block; + *zoom: 1; + *display: inline; + border: solid 1px #91765d; + background: #eee; + color: #333; + vertical-align: middle; +} +.sp-replacer:hover, .sp-replacer.sp-active { + border-color: #F0C49B; + color: #111; +} +.sp-replacer.sp-disabled { + cursor:default; + border-color: silver; + color: silver; +} +.sp-dd { + padding: 2px 0; + height: 16px; + line-height: 16px; + float:left; + font-size:10px; +} +.sp-preview { + position:relative; + width:25px; + height: 20px; + border: solid 1px #222; + margin-right: 5px; + float:left; + z-index: 0; +} + +.sp-palette { + *width: 220px; + max-width: 220px; +} +.sp-palette .sp-thumb-el { + width:16px; + height: 16px; + margin:2px 1px; + border: solid 1px #d0d0d0; +} + +.sp-container { + padding-bottom:0; +} + + +/* Buttons: http://hellohappy.org/css3-buttons/ */ +.sp-container button { + background-color: #eeeeee; + background-image: -webkit-linear-gradient(top, #eeeeee, #cccccc); + background-image: -moz-linear-gradient(top, #eeeeee, #cccccc); + background-image: -ms-linear-gradient(top, #eeeeee, #cccccc); + background-image: -o-linear-gradient(top, #eeeeee, #cccccc); + background-image: linear-gradient(to bottom, #eeeeee, #cccccc); + border: 1px solid #ccc; + border-bottom: 1px solid #bbb; + border-radius: 3px; + color: #333; + font-size: 14px; + line-height: 1; + padding: 5px 4px; + text-align: center; + text-shadow: 0 1px 0 #eee; + vertical-align: middle; +} +.sp-container button:hover { + background-color: #dddddd; + background-image: -webkit-linear-gradient(top, #dddddd, #bbbbbb); + background-image: -moz-linear-gradient(top, #dddddd, #bbbbbb); + background-image: -ms-linear-gradient(top, #dddddd, #bbbbbb); + background-image: -o-linear-gradient(top, #dddddd, #bbbbbb); + background-image: linear-gradient(to bottom, #dddddd, #bbbbbb); + border: 1px solid #bbb; + border-bottom: 1px solid #999; + cursor: pointer; + text-shadow: 0 1px 0 #ddd; +} +.sp-container button:active { + border: 1px solid #aaa; + border-bottom: 1px solid #888; + -webkit-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; + -moz-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; + -ms-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; + -o-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; + box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; +} +.sp-cancel { + font-size: 11px; + color: #d93f3f !important; + margin:0; + padding:2px; + margin-right: 5px; + vertical-align: middle; + text-decoration:none; + +} +.sp-cancel:hover { + color: #d93f3f !important; + text-decoration: underline; +} + + +.sp-palette span:hover, .sp-palette span.sp-thumb-active { + border-color: #000; +} + +.sp-preview, .sp-alpha, .sp-thumb-el { + position:relative; + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==); +} +.sp-preview-inner, .sp-alpha-inner, .sp-thumb-inner { + display:block; + position:absolute; + top:0;left:0;bottom:0;right:0; +} + +.sp-palette .sp-thumb-inner { + background-position: 50% 50%; + background-repeat: no-repeat; +} + +.sp-palette .sp-thumb-light.sp-thumb-active .sp-thumb-inner { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAIVJREFUeNpiYBhsgJFMffxAXABlN5JruT4Q3wfi/0DsT64h8UD8HmpIPCWG/KemIfOJCUB+Aoacx6EGBZyHBqI+WsDCwuQ9mhxeg2A210Ntfo8klk9sOMijaURm7yc1UP2RNCMbKE9ODK1HM6iegYLkfx8pligC9lCD7KmRof0ZhjQACDAAceovrtpVBRkAAAAASUVORK5CYII=); +} + +.sp-palette .sp-thumb-dark.sp-thumb-active .sp-thumb-inner { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAadEVYdFNvZnR3YXJlAFBhaW50Lk5FVCB2My41LjEwMPRyoQAAAMdJREFUOE+tkgsNwzAMRMugEAahEAahEAZhEAqlEAZhEAohEAYh81X2dIm8fKpEspLGvudPOsUYpxE2BIJCroJmEW9qJ+MKaBFhEMNabSy9oIcIPwrB+afvAUFoK4H0tMaQ3XtlrggDhOVVMuT4E5MMG0FBbCEYzjYT7OxLEvIHQLY2zWwQ3D+9luyOQTfKDiFD3iUIfPk8VqrKjgAiSfGFPecrg6HN6m/iBcwiDAo7WiBeawa+Kwh7tZoSCGLMqwlSAzVDhoK+6vH4G0P5wdkAAAAASUVORK5CYII=); +} + +.sp-clear-display { + background-repeat:no-repeat; + background-position: center; + background-image: url(data:image/gif;base64,R0lGODlhFAAUAPcAAAAAAJmZmZ2dnZ6enqKioqOjo6SkpKWlpaampqenp6ioqKmpqaqqqqurq/Hx8fLy8vT09PX19ff39/j4+Pn5+fr6+vv7+wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAP8ALAAAAAAUABQAAAihAP9FoPCvoMGDBy08+EdhQAIJCCMybCDAAYUEARBAlFiQQoMABQhKUJBxY0SPICEYHBnggEmDKAuoPMjS5cGYMxHW3IiT478JJA8M/CjTZ0GgLRekNGpwAsYABHIypcAgQMsITDtWJYBR6NSqMico9cqR6tKfY7GeBCuVwlipDNmefAtTrkSzB1RaIAoXodsABiZAEFB06gIBWC1mLVgBa0AAOw==); +} diff --git a/scp/js/scp.js b/scp/js/scp.js index c825c051cc64764435c33debafd976b3c0c53324..82e2cd81cf5c3c117402c661f0d64ca782a3956c 100644 --- a/scp/js/scp.js +++ b/scp/js/scp.js @@ -945,7 +945,7 @@ $(document).on('click.tab', 'ul.tabs > li > a', function(e) { $ul.children('li.active').removeClass('active'); $(this).closest('li').addClass('active'); $container.children('.tab_content').hide(); - $tab.fadeIn('fast'); + $tab.fadeIn('fast').show(); return false; } @@ -1228,3 +1228,51 @@ window.relativeAdjust = setInterval(function() { }); }, 20000); +// Add 'afterShow' event to jQuery elements, +// thanks http://stackoverflow.com/a/1225238/1025836 +(function ($) { + var _oldShow = $.fn.show; + + $.fn.show = function (/*speed, easing, callback*/) { + var argsArray = Array.prototype.slice.call(arguments), + duration = argsArray[0], + easing, + callback, + callbackArgIndex; + + // jQuery recursively calls show sometimes; we shouldn't + // handle such situations. Pass it to original show method. + if (!this.selector) { + _oldShow.apply(this, argsArray); + return this; + } + + if (argsArray.length === 2) { + if ($.isFunction(argsArray[1])) { + callback = argsArray[1]; + callbackArgIndex = 1; + } else { + easing = argsArray[1]; + } + } else if (argsArray.length === 3) { + easing = argsArray[1]; + callback = argsArray[2]; + callbackArgIndex = 2; + } + return $(this).each(function () { + var obj = $(this), + oldCallback = callback, + newCallback = function () { + if ($.isFunction(oldCallback)) { + oldCallback.apply(obj); + } + }; + if (callback) { + argsArray[callbackArgIndex] = newCallback; + } + obj.trigger('beforeShow'); + _oldShow.apply(obj, argsArray); + obj.trigger('afterShow'); + }); + }; +})(jQuery); diff --git a/scp/js/spectrum.js b/scp/js/spectrum.js new file mode 100644 index 0000000000000000000000000000000000000000..39ffcd7e5c5c9db1942d62e7d4a915c1530cff44 --- /dev/null +++ b/scp/js/spectrum.js @@ -0,0 +1,2317 @@ +// Spectrum Colorpicker v1.7.1 +// https://github.com/bgrins/spectrum +// Author: Brian Grinstead +// License: MIT + +(function (factory) { + "use strict"; + + if (typeof define === 'function' && define.amd) { // AMD + define(['jquery'], factory); + } + else if (typeof exports == "object" && typeof module == "object") { // CommonJS + module.exports = factory; + } + else { // Browser + factory(jQuery); + } +})(function($, undefined) { + "use strict"; + + var defaultOpts = { + + // Callbacks + beforeShow: noop, + move: noop, + change: noop, + show: noop, + hide: noop, + + // Options + color: false, + flat: false, + showInput: false, + allowEmpty: false, + showButtons: true, + clickoutFiresChange: true, + showInitial: false, + showPalette: false, + showPaletteOnly: false, + hideAfterPaletteSelect: false, + togglePaletteOnly: false, + showSelectionPalette: true, + localStorageKey: false, + appendTo: "body", + maxSelectionSize: 7, + cancelText: "cancel", + chooseText: "choose", + togglePaletteMoreText: "more", + togglePaletteLessText: "less", + clearText: "Clear Color Selection", + noColorSelectedText: "No Color Selected", + preferredFormat: false, + className: "", // Deprecated - use containerClassName and replacerClassName instead. + containerClassName: "", + replacerClassName: "", + showAlpha: false, + theme: "sp-light", + palette: [["#ffffff", "#000000", "#ff0000", "#ff8000", "#ffff00", "#008000", "#0000ff", "#4b0082", "#9400d3"]], + selectionPalette: [], + disabled: false, + offset: null + }, + spectrums = [], + IE = !!/msie/i.exec( window.navigator.userAgent ), + rgbaSupport = (function() { + function contains( str, substr ) { + return !!~('' + str).indexOf(substr); + } + + var elem = document.createElement('div'); + var style = elem.style; + style.cssText = 'background-color:rgba(0,0,0,.5)'; + return contains(style.backgroundColor, 'rgba') || contains(style.backgroundColor, 'hsla'); + })(), + replaceInput = [ + "<div class='sp-replacer'>", + "<div class='sp-preview'><div class='sp-preview-inner'></div></div>", + "<div class='sp-dd'>▼</div>", + "</div>" + ].join(''), + markup = (function () { + + // IE does not support gradients with multiple stops, so we need to simulate + // that for the rainbow slider with 8 divs that each have a single gradient + var gradientFix = ""; + if (IE) { + for (var i = 1; i <= 6; i++) { + gradientFix += "<div class='sp-" + i + "'></div>"; + } + } + + return [ + "<div class='sp-container sp-hidden'>", + "<div class='sp-palette-container'>", + "<div class='sp-palette sp-thumb sp-cf'></div>", + "<div class='sp-palette-button-container sp-cf'>", + "<button type='button' class='sp-palette-toggle'></button>", + "</div>", + "</div>", + "<div class='sp-picker-container'>", + "<div class='sp-top sp-cf'>", + "<div class='sp-fill'></div>", + "<div class='sp-top-inner'>", + "<div class='sp-color'>", + "<div class='sp-sat'>", + "<div class='sp-val'>", + "<div class='sp-dragger'></div>", + "</div>", + "</div>", + "</div>", + "<div class='sp-clear sp-clear-display'>", + "</div>", + "<div class='sp-hue'>", + "<div class='sp-slider'></div>", + gradientFix, + "</div>", + "</div>", + "<div class='sp-alpha'><div class='sp-alpha-inner'><div class='sp-alpha-handle'></div></div></div>", + "</div>", + "<div class='sp-input-container sp-cf'>", + "<input class='sp-input' type='text' spellcheck='false' />", + "</div>", + "<div class='sp-initial sp-thumb sp-cf'></div>", + "<div class='sp-button-container sp-cf'>", + "<a class='sp-cancel' href='#'></a>", + "<button type='button' class='sp-choose'></button>", + "</div>", + "</div>", + "</div>" + ].join(""); + })(); + + function paletteTemplate (p, color, className, opts) { + var html = []; + for (var i = 0; i < p.length; i++) { + var current = p[i]; + if(current) { + var tiny = tinycolor(current); + var c = tiny.toHsl().l < 0.5 ? "sp-thumb-el sp-thumb-dark" : "sp-thumb-el sp-thumb-light"; + c += (tinycolor.equals(color, current)) ? " sp-thumb-active" : ""; + var formattedString = tiny.toString(opts.preferredFormat || "rgb"); + var swatchStyle = rgbaSupport ? ("background-color:" + tiny.toRgbString()) : "filter:" + tiny.toFilter(); + html.push('<span title="' + formattedString + '" data-color="' + tiny.toRgbString() + '" class="' + c + '"><span class="sp-thumb-inner" style="' + swatchStyle + ';" /></span>'); + } else { + var cls = 'sp-clear-display'; + html.push($('<div />') + .append($('<span data-color="" style="background-color:transparent;" class="' + cls + '"></span>') + .attr('title', opts.noColorSelectedText) + ) + .html() + ); + } + } + return "<div class='sp-cf " + className + "'>" + html.join('') + "</div>"; + } + + function hideAll() { + for (var i = 0; i < spectrums.length; i++) { + if (spectrums[i]) { + spectrums[i].hide(); + } + } + } + + function instanceOptions(o, callbackContext) { + var opts = $.extend({}, defaultOpts, o); + opts.callbacks = { + 'move': bind(opts.move, callbackContext), + 'change': bind(opts.change, callbackContext), + 'show': bind(opts.show, callbackContext), + 'hide': bind(opts.hide, callbackContext), + 'beforeShow': bind(opts.beforeShow, callbackContext) + }; + + return opts; + } + + function spectrum(element, o) { + + var opts = instanceOptions(o, element), + flat = opts.flat, + showSelectionPalette = opts.showSelectionPalette, + localStorageKey = opts.localStorageKey, + theme = opts.theme, + callbacks = opts.callbacks, + resize = throttle(reflow, 10), + visible = false, + isDragging = false, + dragWidth = 0, + dragHeight = 0, + dragHelperHeight = 0, + slideHeight = 0, + slideWidth = 0, + alphaWidth = 0, + alphaSlideHelperWidth = 0, + slideHelperHeight = 0, + currentHue = 0, + currentSaturation = 0, + currentValue = 0, + currentAlpha = 1, + palette = [], + paletteArray = [], + paletteLookup = {}, + selectionPalette = opts.selectionPalette.slice(0), + maxSelectionSize = opts.maxSelectionSize, + draggingClass = "sp-dragging", + shiftMovementDirection = null; + + var doc = element.ownerDocument, + body = doc.body, + boundElement = $(element), + disabled = false, + container = $(markup, doc).addClass(theme), + pickerContainer = container.find(".sp-picker-container"), + dragger = container.find(".sp-color"), + dragHelper = container.find(".sp-dragger"), + slider = container.find(".sp-hue"), + slideHelper = container.find(".sp-slider"), + alphaSliderInner = container.find(".sp-alpha-inner"), + alphaSlider = container.find(".sp-alpha"), + alphaSlideHelper = container.find(".sp-alpha-handle"), + textInput = container.find(".sp-input"), + paletteContainer = container.find(".sp-palette"), + initialColorContainer = container.find(".sp-initial"), + cancelButton = container.find(".sp-cancel"), + clearButton = container.find(".sp-clear"), + chooseButton = container.find(".sp-choose"), + toggleButton = container.find(".sp-palette-toggle"), + isInput = boundElement.is("input"), + isInputTypeColor = isInput && boundElement.attr("type") === "color" && inputTypeColorSupport(), + shouldReplace = isInput && !flat, + replacer = (shouldReplace) ? $(replaceInput).addClass(theme).addClass(opts.className).addClass(opts.replacerClassName) : $([]), + offsetElement = (shouldReplace) ? replacer : boundElement, + previewElement = replacer.find(".sp-preview-inner"), + initialColor = opts.color || (isInput && boundElement.val()), + colorOnShow = false, + preferredFormat = opts.preferredFormat, + currentPreferredFormat = preferredFormat, + clickoutFiresChange = !opts.showButtons || opts.clickoutFiresChange, + isEmpty = !initialColor, + allowEmpty = opts.allowEmpty && !isInputTypeColor; + + function applyOptions() { + + if (opts.showPaletteOnly) { + opts.showPalette = true; + } + + toggleButton.text(opts.showPaletteOnly ? opts.togglePaletteMoreText : opts.togglePaletteLessText); + + if (opts.palette) { + palette = opts.palette.slice(0); + paletteArray = $.isArray(palette[0]) ? palette : [palette]; + paletteLookup = {}; + for (var i = 0; i < paletteArray.length; i++) { + for (var j = 0; j < paletteArray[i].length; j++) { + var rgb = tinycolor(paletteArray[i][j]).toRgbString(); + paletteLookup[rgb] = true; + } + } + } + + container.toggleClass("sp-flat", flat); + container.toggleClass("sp-input-disabled", !opts.showInput); + container.toggleClass("sp-alpha-enabled", opts.showAlpha); + container.toggleClass("sp-clear-enabled", allowEmpty); + container.toggleClass("sp-buttons-disabled", !opts.showButtons); + container.toggleClass("sp-palette-buttons-disabled", !opts.togglePaletteOnly); + container.toggleClass("sp-palette-disabled", !opts.showPalette); + container.toggleClass("sp-palette-only", opts.showPaletteOnly); + container.toggleClass("sp-initial-disabled", !opts.showInitial); + container.addClass(opts.className).addClass(opts.containerClassName); + + reflow(); + } + + function initialize() { + + if (IE) { + container.find("*:not(input)").attr("unselectable", "on"); + } + + applyOptions(); + + if (shouldReplace) { + boundElement.after(replacer).hide(); + } + + if (!allowEmpty) { + clearButton.hide(); + } + + if (flat) { + boundElement.after(container).hide(); + } + else { + + var appendTo = opts.appendTo === "parent" ? boundElement.parent() : $(opts.appendTo); + if (appendTo.length !== 1) { + appendTo = $("body"); + } + + appendTo.append(container); + } + + updateSelectionPaletteFromStorage(); + + offsetElement.bind("click.spectrum touchstart.spectrum", function (e) { + if (!disabled) { + toggle(); + } + + e.stopPropagation(); + + if (!$(e.target).is("input")) { + e.preventDefault(); + } + }); + + if(boundElement.is(":disabled") || (opts.disabled === true)) { + disable(); + } + + // Prevent clicks from bubbling up to document. This would cause it to be hidden. + container.click(stopPropagation); + + // Handle user typed input + textInput.change(setFromTextInput); + textInput.bind("paste", function () { + setTimeout(setFromTextInput, 1); + }); + textInput.keydown(function (e) { if (e.keyCode == 13) { setFromTextInput(); } }); + + cancelButton.text(opts.cancelText); + cancelButton.bind("click.spectrum", function (e) { + e.stopPropagation(); + e.preventDefault(); + revert(); + hide(); + }); + + clearButton.attr("title", opts.clearText); + clearButton.bind("click.spectrum", function (e) { + e.stopPropagation(); + e.preventDefault(); + isEmpty = true; + move(); + + if(flat) { + //for the flat style, this is a change event + updateOriginalInput(true); + } + }); + + chooseButton.text(opts.chooseText); + chooseButton.bind("click.spectrum", function (e) { + e.stopPropagation(); + e.preventDefault(); + + if (IE && textInput.is(":focus")) { + textInput.trigger('change'); + } + + if (isValid()) { + updateOriginalInput(true); + hide(); + } + }); + + toggleButton.text(opts.showPaletteOnly ? opts.togglePaletteMoreText : opts.togglePaletteLessText); + toggleButton.bind("click.spectrum", function (e) { + e.stopPropagation(); + e.preventDefault(); + + opts.showPaletteOnly = !opts.showPaletteOnly; + + // To make sure the Picker area is drawn on the right, next to the + // Palette area (and not below the palette), first move the Palette + // to the left to make space for the picker, plus 5px extra. + // The 'applyOptions' function puts the whole container back into place + // and takes care of the button-text and the sp-palette-only CSS class. + if (!opts.showPaletteOnly && !flat) { + container.css('left', '-=' + (pickerContainer.outerWidth(true) + 5)); + } + applyOptions(); + }); + + draggable(alphaSlider, function (dragX, dragY, e) { + currentAlpha = (dragX / alphaWidth); + isEmpty = false; + if (e.shiftKey) { + currentAlpha = Math.round(currentAlpha * 10) / 10; + } + + move(); + }, dragStart, dragStop); + + draggable(slider, function (dragX, dragY) { + currentHue = parseFloat(dragY / slideHeight); + isEmpty = false; + if (!opts.showAlpha) { + currentAlpha = 1; + } + move(); + }, dragStart, dragStop); + + draggable(dragger, function (dragX, dragY, e) { + + // shift+drag should snap the movement to either the x or y axis. + if (!e.shiftKey) { + shiftMovementDirection = null; + } + else if (!shiftMovementDirection) { + var oldDragX = currentSaturation * dragWidth; + var oldDragY = dragHeight - (currentValue * dragHeight); + var furtherFromX = Math.abs(dragX - oldDragX) > Math.abs(dragY - oldDragY); + + shiftMovementDirection = furtherFromX ? "x" : "y"; + } + + var setSaturation = !shiftMovementDirection || shiftMovementDirection === "x"; + var setValue = !shiftMovementDirection || shiftMovementDirection === "y"; + + if (setSaturation) { + currentSaturation = parseFloat(dragX / dragWidth); + } + if (setValue) { + currentValue = parseFloat((dragHeight - dragY) / dragHeight); + } + + isEmpty = false; + if (!opts.showAlpha) { + currentAlpha = 1; + } + + move(); + + }, dragStart, dragStop); + + if (!!initialColor) { + set(initialColor); + + // In case color was black - update the preview UI and set the format + // since the set function will not run (default color is black). + updateUI(); + currentPreferredFormat = preferredFormat || tinycolor(initialColor).format; + + addColorToSelectionPalette(initialColor); + } + else { + updateUI(); + } + + if (flat) { + show(); + } + + function paletteElementClick(e) { + if (e.data && e.data.ignore) { + set($(e.target).closest(".sp-thumb-el").data("color")); + move(); + } + else { + set($(e.target).closest(".sp-thumb-el").data("color")); + move(); + updateOriginalInput(true); + if (opts.hideAfterPaletteSelect) { + hide(); + } + } + + return false; + } + + var paletteEvent = IE ? "mousedown.spectrum" : "click.spectrum touchstart.spectrum"; + paletteContainer.delegate(".sp-thumb-el", paletteEvent, paletteElementClick); + initialColorContainer.delegate(".sp-thumb-el:nth-child(1)", paletteEvent, { ignore: true }, paletteElementClick); + } + + function updateSelectionPaletteFromStorage() { + + if (localStorageKey && window.localStorage) { + + // Migrate old palettes over to new format. May want to remove this eventually. + try { + var oldPalette = window.localStorage[localStorageKey].split(",#"); + if (oldPalette.length > 1) { + delete window.localStorage[localStorageKey]; + $.each(oldPalette, function(i, c) { + addColorToSelectionPalette(c); + }); + } + } + catch(e) { } + + try { + selectionPalette = window.localStorage[localStorageKey].split(";"); + } + catch (e) { } + } + } + + function addColorToSelectionPalette(color) { + if (showSelectionPalette) { + var rgb = tinycolor(color).toRgbString(); + if (!paletteLookup[rgb] && $.inArray(rgb, selectionPalette) === -1) { + selectionPalette.push(rgb); + while(selectionPalette.length > maxSelectionSize) { + selectionPalette.shift(); + } + } + + if (localStorageKey && window.localStorage) { + try { + window.localStorage[localStorageKey] = selectionPalette.join(";"); + } + catch(e) { } + } + } + } + + function getUniqueSelectionPalette() { + var unique = []; + if (opts.showPalette) { + for (var i = 0; i < selectionPalette.length; i++) { + var rgb = tinycolor(selectionPalette[i]).toRgbString(); + + if (!paletteLookup[rgb]) { + unique.push(selectionPalette[i]); + } + } + } + + return unique.reverse().slice(0, opts.maxSelectionSize); + } + + function drawPalette() { + + var currentColor = get(); + + var html = $.map(paletteArray, function (palette, i) { + return paletteTemplate(palette, currentColor, "sp-palette-row sp-palette-row-" + i, opts); + }); + + updateSelectionPaletteFromStorage(); + + if (selectionPalette) { + html.push(paletteTemplate(getUniqueSelectionPalette(), currentColor, "sp-palette-row sp-palette-row-selection", opts)); + } + + paletteContainer.html(html.join("")); + } + + function drawInitial() { + if (opts.showInitial) { + var initial = colorOnShow; + var current = get(); + initialColorContainer.html(paletteTemplate([initial, current], current, "sp-palette-row-initial", opts)); + } + } + + function dragStart() { + if (dragHeight <= 0 || dragWidth <= 0 || slideHeight <= 0) { + reflow(); + } + isDragging = true; + container.addClass(draggingClass); + shiftMovementDirection = null; + boundElement.trigger('dragstart.spectrum', [ get() ]); + } + + function dragStop() { + isDragging = false; + container.removeClass(draggingClass); + boundElement.trigger('dragstop.spectrum', [ get() ]); + } + + function setFromTextInput() { + + var value = textInput.val(); + + if ((value === null || value === "") && allowEmpty) { + set(null); + updateOriginalInput(true); + } + else { + var tiny = tinycolor(value); + if (tiny.isValid()) { + set(tiny); + updateOriginalInput(true); + } + else { + textInput.addClass("sp-validation-error"); + } + } + } + + function toggle() { + if (visible) { + hide(); + } + else { + show(); + } + } + + function show() { + var event = $.Event('beforeShow.spectrum'); + + if (visible) { + reflow(); + return; + } + + boundElement.trigger(event, [ get() ]); + + if (callbacks.beforeShow(get()) === false || event.isDefaultPrevented()) { + return; + } + + hideAll(); + visible = true; + + $(doc).bind("keydown.spectrum", onkeydown); + $(doc).bind("click.spectrum", clickout); + $(window).bind("resize.spectrum", resize); + replacer.addClass("sp-active"); + container.removeClass("sp-hidden"); + + reflow(); + updateUI(); + + colorOnShow = get(); + + drawInitial(); + callbacks.show(colorOnShow); + boundElement.trigger('show.spectrum', [ colorOnShow ]); + } + + function onkeydown(e) { + // Close on ESC + if (e.keyCode === 27) { + hide(); + } + } + + function clickout(e) { + // Return on right click. + if (e.button == 2) { return; } + + // If a drag event was happening during the mouseup, don't hide + // on click. + if (isDragging) { return; } + + if (clickoutFiresChange) { + updateOriginalInput(true); + } + else { + revert(); + } + hide(); + } + + function hide() { + // Return if hiding is unnecessary + if (!visible || flat) { return; } + visible = false; + + $(doc).unbind("keydown.spectrum", onkeydown); + $(doc).unbind("click.spectrum", clickout); + $(window).unbind("resize.spectrum", resize); + + replacer.removeClass("sp-active"); + container.addClass("sp-hidden"); + + callbacks.hide(get()); + boundElement.trigger('hide.spectrum', [ get() ]); + } + + function revert() { + set(colorOnShow, true); + } + + function set(color, ignoreFormatChange) { + if (tinycolor.equals(color, get())) { + // Update UI just in case a validation error needs + // to be cleared. + updateUI(); + return; + } + + var newColor, newHsv; + if (!color && allowEmpty) { + isEmpty = true; + } else { + isEmpty = false; + newColor = tinycolor(color); + newHsv = newColor.toHsv(); + + currentHue = (newHsv.h % 360) / 360; + currentSaturation = newHsv.s; + currentValue = newHsv.v; + currentAlpha = newHsv.a; + } + updateUI(); + + if (newColor && newColor.isValid() && !ignoreFormatChange) { + currentPreferredFormat = preferredFormat || newColor.getFormat(); + } + } + + function get(opts) { + opts = opts || { }; + + if (allowEmpty && isEmpty) { + return null; + } + + return tinycolor.fromRatio({ + h: currentHue, + s: currentSaturation, + v: currentValue, + a: Math.round(currentAlpha * 100) / 100 + }, { format: opts.format || currentPreferredFormat }); + } + + function isValid() { + return !textInput.hasClass("sp-validation-error"); + } + + function move() { + updateUI(); + + callbacks.move(get()); + boundElement.trigger('move.spectrum', [ get() ]); + } + + function updateUI() { + + textInput.removeClass("sp-validation-error"); + + updateHelperLocations(); + + // Update dragger background color (gradients take care of saturation and value). + var flatColor = tinycolor.fromRatio({ h: currentHue, s: 1, v: 1 }); + dragger.css("background-color", flatColor.toHexString()); + + // Get a format that alpha will be included in (hex and names ignore alpha) + var format = currentPreferredFormat; + if (currentAlpha < 1 && !(currentAlpha === 0 && format === "name")) { + if (format === "hex" || format === "hex3" || format === "hex6" || format === "name") { + format = "rgb"; + } + } + + var realColor = get({ format: format }), + displayColor = ''; + + //reset background info for preview element + previewElement.removeClass("sp-clear-display"); + previewElement.css('background-color', 'transparent'); + + if (!realColor && allowEmpty) { + // Update the replaced elements background with icon indicating no color selection + previewElement.addClass("sp-clear-display"); + } + else { + var realHex = realColor.toHexString(), + realRgb = realColor.toRgbString(); + + // Update the replaced elements background color (with actual selected color) + if (rgbaSupport || realColor.alpha === 1) { + previewElement.css("background-color", realRgb); + } + else { + previewElement.css("background-color", "transparent"); + previewElement.css("filter", realColor.toFilter()); + } + + if (opts.showAlpha) { + var rgb = realColor.toRgb(); + rgb.a = 0; + var realAlpha = tinycolor(rgb).toRgbString(); + var gradient = "linear-gradient(left, " + realAlpha + ", " + realHex + ")"; + + if (IE) { + alphaSliderInner.css("filter", tinycolor(realAlpha).toFilter({ gradientType: 1 }, realHex)); + } + else { + alphaSliderInner.css("background", "-webkit-" + gradient); + alphaSliderInner.css("background", "-moz-" + gradient); + alphaSliderInner.css("background", "-ms-" + gradient); + // Use current syntax gradient on unprefixed property. + alphaSliderInner.css("background", + "linear-gradient(to right, " + realAlpha + ", " + realHex + ")"); + } + } + + displayColor = realColor.toString(format); + } + + // Update the text entry input as it changes happen + if (opts.showInput) { + textInput.val(displayColor); + } + + if (opts.showPalette) { + drawPalette(); + } + + drawInitial(); + } + + function updateHelperLocations() { + var s = currentSaturation; + var v = currentValue; + + if(allowEmpty && isEmpty) { + //if selected color is empty, hide the helpers + alphaSlideHelper.hide(); + slideHelper.hide(); + dragHelper.hide(); + } + else { + //make sure helpers are visible + alphaSlideHelper.show(); + slideHelper.show(); + dragHelper.show(); + + // Where to show the little circle in that displays your current selected color + var dragX = s * dragWidth; + var dragY = dragHeight - (v * dragHeight); + dragX = Math.max( + -dragHelperHeight, + Math.min(dragWidth - dragHelperHeight, dragX - dragHelperHeight) + ); + dragY = Math.max( + -dragHelperHeight, + Math.min(dragHeight - dragHelperHeight, dragY - dragHelperHeight) + ); + dragHelper.css({ + "top": dragY + "px", + "left": dragX + "px" + }); + + var alphaX = currentAlpha * alphaWidth; + alphaSlideHelper.css({ + "left": (alphaX - (alphaSlideHelperWidth / 2)) + "px" + }); + + // Where to show the bar that displays your current selected hue + var slideY = (currentHue) * slideHeight; + slideHelper.css({ + "top": (slideY - slideHelperHeight) + "px" + }); + } + } + + function updateOriginalInput(fireCallback) { + var color = get(), + displayColor = '', + hasChanged = !tinycolor.equals(color, colorOnShow); + + if (color) { + displayColor = color.toString(currentPreferredFormat); + // Update the selection palette with the current color + addColorToSelectionPalette(color); + } + + if (isInput) { + boundElement.val(displayColor); + } + + if (fireCallback && hasChanged) { + callbacks.change(color); + boundElement.trigger('change', [ color ]); + } + } + + function reflow() { + dragWidth = dragger.width(); + dragHeight = dragger.height(); + dragHelperHeight = dragHelper.height(); + slideWidth = slider.width(); + slideHeight = slider.height(); + slideHelperHeight = slideHelper.height(); + alphaWidth = alphaSlider.width(); + alphaSlideHelperWidth = alphaSlideHelper.width(); + + if (!flat) { + container.css("position", "absolute"); + if (opts.offset) { + container.offset(opts.offset); + } else { + container.offset(getOffset(container, offsetElement)); + } + } + + updateHelperLocations(); + + if (opts.showPalette) { + drawPalette(); + } + + boundElement.trigger('reflow.spectrum'); + } + + function destroy() { + boundElement.show(); + offsetElement.unbind("click.spectrum touchstart.spectrum"); + container.remove(); + replacer.remove(); + spectrums[spect.id] = null; + } + + function option(optionName, optionValue) { + if (optionName === undefined) { + return $.extend({}, opts); + } + if (optionValue === undefined) { + return opts[optionName]; + } + + opts[optionName] = optionValue; + applyOptions(); + } + + function enable() { + disabled = false; + boundElement.attr("disabled", false); + offsetElement.removeClass("sp-disabled"); + } + + function disable() { + hide(); + disabled = true; + boundElement.attr("disabled", true); + offsetElement.addClass("sp-disabled"); + } + + function setOffset(coord) { + opts.offset = coord; + reflow(); + } + + initialize(); + + var spect = { + show: show, + hide: hide, + toggle: toggle, + reflow: reflow, + option: option, + enable: enable, + disable: disable, + offset: setOffset, + set: function (c) { + set(c); + updateOriginalInput(); + }, + get: get, + destroy: destroy, + container: container + }; + + spect.id = spectrums.push(spect) - 1; + + return spect; + } + + /** + * checkOffset - get the offset below/above and left/right element depending on screen position + * Thanks https://github.com/jquery/jquery-ui/blob/master/ui/jquery.ui.datepicker.js + */ + function getOffset(picker, input) { + var extraY = 0; + var dpWidth = picker.outerWidth(); + var dpHeight = picker.outerHeight(); + var inputHeight = input.outerHeight(); + var doc = picker[0].ownerDocument; + var docElem = doc.documentElement; + var viewWidth = docElem.clientWidth + $(doc).scrollLeft(); + var viewHeight = docElem.clientHeight + $(doc).scrollTop(); + var offset = input.offset(); + offset.top += inputHeight; + + offset.left -= + Math.min(offset.left, (offset.left + dpWidth > viewWidth && viewWidth > dpWidth) ? + Math.abs(offset.left + dpWidth - viewWidth) : 0); + + offset.top -= + Math.min(offset.top, ((offset.top + dpHeight > viewHeight && viewHeight > dpHeight) ? + Math.abs(dpHeight + inputHeight - extraY) : extraY)); + + return offset; + } + + /** + * noop - do nothing + */ + function noop() { + + } + + /** + * stopPropagation - makes the code only doing this a little easier to read in line + */ + function stopPropagation(e) { + e.stopPropagation(); + } + + /** + * Create a function bound to a given object + * Thanks to underscore.js + */ + function bind(func, obj) { + var slice = Array.prototype.slice; + var args = slice.call(arguments, 2); + return function () { + return func.apply(obj, args.concat(slice.call(arguments))); + }; + } + + /** + * Lightweight drag helper. Handles containment within the element, so that + * when dragging, the x is within [0,element.width] and y is within [0,element.height] + */ + function draggable(element, onmove, onstart, onstop) { + onmove = onmove || function () { }; + onstart = onstart || function () { }; + onstop = onstop || function () { }; + var doc = document; + var dragging = false; + var offset = {}; + var maxHeight = 0; + var maxWidth = 0; + var hasTouch = ('ontouchstart' in window); + + var duringDragEvents = {}; + duringDragEvents["selectstart"] = prevent; + duringDragEvents["dragstart"] = prevent; + duringDragEvents["touchmove mousemove"] = move; + duringDragEvents["touchend mouseup"] = stop; + + function prevent(e) { + if (e.stopPropagation) { + e.stopPropagation(); + } + if (e.preventDefault) { + e.preventDefault(); + } + e.returnValue = false; + } + + function move(e) { + if (dragging) { + // Mouseup happened outside of window + if (IE && doc.documentMode < 9 && !e.button) { + return stop(); + } + + var t0 = e.originalEvent && e.originalEvent.touches && e.originalEvent.touches[0]; + var pageX = t0 && t0.pageX || e.pageX; + var pageY = t0 && t0.pageY || e.pageY; + + var dragX = Math.max(0, Math.min(pageX - offset.left, maxWidth)); + var dragY = Math.max(0, Math.min(pageY - offset.top, maxHeight)); + + if (hasTouch) { + // Stop scrolling in iOS + prevent(e); + } + + onmove.apply(element, [dragX, dragY, e]); + } + } + + function start(e) { + var rightclick = (e.which) ? (e.which == 3) : (e.button == 2); + + if (!rightclick && !dragging) { + if (onstart.apply(element, arguments) !== false) { + dragging = true; + maxHeight = $(element).height(); + maxWidth = $(element).width(); + offset = $(element).offset(); + + $(doc).bind(duringDragEvents); + $(doc.body).addClass("sp-dragging"); + + move(e); + + prevent(e); + } + } + } + + function stop() { + if (dragging) { + $(doc).unbind(duringDragEvents); + $(doc.body).removeClass("sp-dragging"); + + // Wait a tick before notifying observers to allow the click event + // to fire in Chrome. + setTimeout(function() { + onstop.apply(element, arguments); + }, 0); + } + dragging = false; + } + + $(element).bind("touchstart mousedown", start); + } + + function throttle(func, wait, debounce) { + var timeout; + return function () { + var context = this, args = arguments; + var throttler = function () { + timeout = null; + func.apply(context, args); + }; + if (debounce) clearTimeout(timeout); + if (debounce || !timeout) timeout = setTimeout(throttler, wait); + }; + } + + function inputTypeColorSupport() { + return $.fn.spectrum.inputTypeColorSupport(); + } + + /** + * Define a jQuery plugin + */ + var dataID = "spectrum.id"; + $.fn.spectrum = function (opts, extra) { + + if (typeof opts == "string") { + + var returnValue = this; + var args = Array.prototype.slice.call( arguments, 1 ); + + this.each(function () { + var spect = spectrums[$(this).data(dataID)]; + if (spect) { + var method = spect[opts]; + if (!method) { + throw new Error( "Spectrum: no such method: '" + opts + "'" ); + } + + if (opts == "get") { + returnValue = spect.get(); + } + else if (opts == "container") { + returnValue = spect.container; + } + else if (opts == "option") { + returnValue = spect.option.apply(spect, args); + } + else if (opts == "destroy") { + spect.destroy(); + $(this).removeData(dataID); + } + else { + method.apply(spect, args); + } + } + }); + + return returnValue; + } + + // Initializing a new instance of spectrum + return this.spectrum("destroy").each(function () { + var options = $.extend({}, opts, $(this).data()); + var spect = spectrum(this, options); + $(this).data(dataID, spect.id); + }); + }; + + $.fn.spectrum.load = true; + $.fn.spectrum.loadOpts = {}; + $.fn.spectrum.draggable = draggable; + $.fn.spectrum.defaults = defaultOpts; + $.fn.spectrum.inputTypeColorSupport = function inputTypeColorSupport() { + if (typeof inputTypeColorSupport._cachedResult === "undefined") { + var colorInput = $("<input type='color'/>")[0]; // if color element is supported, value will default to not null + inputTypeColorSupport._cachedResult = colorInput.type === "color" && colorInput.value !== ""; + } + return inputTypeColorSupport._cachedResult; + }; + + $.spectrum = { }; + $.spectrum.localization = { }; + $.spectrum.palettes = { }; + + $.fn.spectrum.processNativeColorInputs = function () { + var colorInputs = $("input[type=color]"); + if (colorInputs.length && !inputTypeColorSupport()) { + colorInputs.spectrum({ + preferredFormat: "hex6" + }); + } + }; + + // TinyColor v1.1.2 + // https://github.com/bgrins/TinyColor + // Brian Grinstead, MIT License + + (function() { + + var trimLeft = /^[\s,#]+/, + trimRight = /\s+$/, + tinyCounter = 0, + math = Math, + mathRound = math.round, + mathMin = math.min, + mathMax = math.max, + mathRandom = math.random; + + var tinycolor = function(color, opts) { + + color = (color) ? color : ''; + opts = opts || { }; + + // If input is already a tinycolor, return itself + if (color instanceof tinycolor) { + return color; + } + // If we are called as a function, call using new instead + if (!(this instanceof tinycolor)) { + return new tinycolor(color, opts); + } + + var rgb = inputToRGB(color); + this._originalInput = color, + this._r = rgb.r, + this._g = rgb.g, + this._b = rgb.b, + this._a = rgb.a, + this._roundA = mathRound(100*this._a) / 100, + this._format = opts.format || rgb.format; + this._gradientType = opts.gradientType; + + // Don't let the range of [0,255] come back in [0,1]. + // Potentially lose a little bit of precision here, but will fix issues where + // .5 gets interpreted as half of the total, instead of half of 1 + // If it was supposed to be 128, this was already taken care of by `inputToRgb` + if (this._r < 1) { this._r = mathRound(this._r); } + if (this._g < 1) { this._g = mathRound(this._g); } + if (this._b < 1) { this._b = mathRound(this._b); } + + this._ok = rgb.ok; + this._tc_id = tinyCounter++; + }; + + tinycolor.prototype = { + isDark: function() { + return this.getBrightness() < 128; + }, + isLight: function() { + return !this.isDark(); + }, + isValid: function() { + return this._ok; + }, + getOriginalInput: function() { + return this._originalInput; + }, + getFormat: function() { + return this._format; + }, + getAlpha: function() { + return this._a; + }, + getBrightness: function() { + var rgb = this.toRgb(); + return (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000; + }, + setAlpha: function(value) { + this._a = boundAlpha(value); + this._roundA = mathRound(100*this._a) / 100; + return this; + }, + toHsv: function() { + var hsv = rgbToHsv(this._r, this._g, this._b); + return { h: hsv.h * 360, s: hsv.s, v: hsv.v, a: this._a }; + }, + toHsvString: function() { + var hsv = rgbToHsv(this._r, this._g, this._b); + var h = mathRound(hsv.h * 360), s = mathRound(hsv.s * 100), v = mathRound(hsv.v * 100); + return (this._a == 1) ? + "hsv(" + h + ", " + s + "%, " + v + "%)" : + "hsva(" + h + ", " + s + "%, " + v + "%, "+ this._roundA + ")"; + }, + toHsl: function() { + var hsl = rgbToHsl(this._r, this._g, this._b); + return { h: hsl.h * 360, s: hsl.s, l: hsl.l, a: this._a }; + }, + toHslString: function() { + var hsl = rgbToHsl(this._r, this._g, this._b); + var h = mathRound(hsl.h * 360), s = mathRound(hsl.s * 100), l = mathRound(hsl.l * 100); + return (this._a == 1) ? + "hsl(" + h + ", " + s + "%, " + l + "%)" : + "hsla(" + h + ", " + s + "%, " + l + "%, "+ this._roundA + ")"; + }, + toHex: function(allow3Char) { + return rgbToHex(this._r, this._g, this._b, allow3Char); + }, + toHexString: function(allow3Char) { + return '#' + this.toHex(allow3Char); + }, + toHex8: function() { + return rgbaToHex(this._r, this._g, this._b, this._a); + }, + toHex8String: function() { + return '#' + this.toHex8(); + }, + toRgb: function() { + return { r: mathRound(this._r), g: mathRound(this._g), b: mathRound(this._b), a: this._a }; + }, + toRgbString: function() { + return (this._a == 1) ? + "rgb(" + mathRound(this._r) + ", " + mathRound(this._g) + ", " + mathRound(this._b) + ")" : + "rgba(" + mathRound(this._r) + ", " + mathRound(this._g) + ", " + mathRound(this._b) + ", " + this._roundA + ")"; + }, + toPercentageRgb: function() { + return { r: mathRound(bound01(this._r, 255) * 100) + "%", g: mathRound(bound01(this._g, 255) * 100) + "%", b: mathRound(bound01(this._b, 255) * 100) + "%", a: this._a }; + }, + toPercentageRgbString: function() { + return (this._a == 1) ? + "rgb(" + mathRound(bound01(this._r, 255) * 100) + "%, " + mathRound(bound01(this._g, 255) * 100) + "%, " + mathRound(bound01(this._b, 255) * 100) + "%)" : + "rgba(" + mathRound(bound01(this._r, 255) * 100) + "%, " + mathRound(bound01(this._g, 255) * 100) + "%, " + mathRound(bound01(this._b, 255) * 100) + "%, " + this._roundA + ")"; + }, + toName: function() { + if (this._a === 0) { + return "transparent"; + } + + if (this._a < 1) { + return false; + } + + return hexNames[rgbToHex(this._r, this._g, this._b, true)] || false; + }, + toFilter: function(secondColor) { + var hex8String = '#' + rgbaToHex(this._r, this._g, this._b, this._a); + var secondHex8String = hex8String; + var gradientType = this._gradientType ? "GradientType = 1, " : ""; + + if (secondColor) { + var s = tinycolor(secondColor); + secondHex8String = s.toHex8String(); + } + + return "progid:DXImageTransform.Microsoft.gradient("+gradientType+"startColorstr="+hex8String+",endColorstr="+secondHex8String+")"; + }, + toString: function(format) { + var formatSet = !!format; + format = format || this._format; + + var formattedString = false; + var hasAlpha = this._a < 1 && this._a >= 0; + var needsAlphaFormat = !formatSet && hasAlpha && (format === "hex" || format === "hex6" || format === "hex3" || format === "name"); + + if (needsAlphaFormat) { + // Special case for "transparent", all other non-alpha formats + // will return rgba when there is transparency. + if (format === "name" && this._a === 0) { + return this.toName(); + } + return this.toRgbString(); + } + if (format === "rgb") { + formattedString = this.toRgbString(); + } + if (format === "prgb") { + formattedString = this.toPercentageRgbString(); + } + if (format === "hex" || format === "hex6") { + formattedString = this.toHexString(); + } + if (format === "hex3") { + formattedString = this.toHexString(true); + } + if (format === "hex8") { + formattedString = this.toHex8String(); + } + if (format === "name") { + formattedString = this.toName(); + } + if (format === "hsl") { + formattedString = this.toHslString(); + } + if (format === "hsv") { + formattedString = this.toHsvString(); + } + + return formattedString || this.toHexString(); + }, + + _applyModification: function(fn, args) { + var color = fn.apply(null, [this].concat([].slice.call(args))); + this._r = color._r; + this._g = color._g; + this._b = color._b; + this.setAlpha(color._a); + return this; + }, + lighten: function() { + return this._applyModification(lighten, arguments); + }, + brighten: function() { + return this._applyModification(brighten, arguments); + }, + darken: function() { + return this._applyModification(darken, arguments); + }, + desaturate: function() { + return this._applyModification(desaturate, arguments); + }, + saturate: function() { + return this._applyModification(saturate, arguments); + }, + greyscale: function() { + return this._applyModification(greyscale, arguments); + }, + spin: function() { + return this._applyModification(spin, arguments); + }, + + _applyCombination: function(fn, args) { + return fn.apply(null, [this].concat([].slice.call(args))); + }, + analogous: function() { + return this._applyCombination(analogous, arguments); + }, + complement: function() { + return this._applyCombination(complement, arguments); + }, + monochromatic: function() { + return this._applyCombination(monochromatic, arguments); + }, + splitcomplement: function() { + return this._applyCombination(splitcomplement, arguments); + }, + triad: function() { + return this._applyCombination(triad, arguments); + }, + tetrad: function() { + return this._applyCombination(tetrad, arguments); + } + }; + + // If input is an object, force 1 into "1.0" to handle ratios properly + // String input requires "1.0" as input, so 1 will be treated as 1 + tinycolor.fromRatio = function(color, opts) { + if (typeof color == "object") { + var newColor = {}; + for (var i in color) { + if (color.hasOwnProperty(i)) { + if (i === "a") { + newColor[i] = color[i]; + } + else { + newColor[i] = convertToPercentage(color[i]); + } + } + } + color = newColor; + } + + return tinycolor(color, opts); + }; + + // Given a string or object, convert that input to RGB + // Possible string inputs: + // + // "red" + // "#f00" or "f00" + // "#ff0000" or "ff0000" + // "#ff000000" or "ff000000" + // "rgb 255 0 0" or "rgb (255, 0, 0)" + // "rgb 1.0 0 0" or "rgb (1, 0, 0)" + // "rgba (255, 0, 0, 1)" or "rgba 255, 0, 0, 1" + // "rgba (1.0, 0, 0, 1)" or "rgba 1.0, 0, 0, 1" + // "hsl(0, 100%, 50%)" or "hsl 0 100% 50%" + // "hsla(0, 100%, 50%, 1)" or "hsla 0 100% 50%, 1" + // "hsv(0, 100%, 100%)" or "hsv 0 100% 100%" + // + function inputToRGB(color) { + + var rgb = { r: 0, g: 0, b: 0 }; + var a = 1; + var ok = false; + var format = false; + + if (typeof color == "string") { + color = stringInputToObject(color); + } + + if (typeof color == "object") { + if (color.hasOwnProperty("r") && color.hasOwnProperty("g") && color.hasOwnProperty("b")) { + rgb = rgbToRgb(color.r, color.g, color.b); + ok = true; + format = String(color.r).substr(-1) === "%" ? "prgb" : "rgb"; + } + else if (color.hasOwnProperty("h") && color.hasOwnProperty("s") && color.hasOwnProperty("v")) { + color.s = convertToPercentage(color.s); + color.v = convertToPercentage(color.v); + rgb = hsvToRgb(color.h, color.s, color.v); + ok = true; + format = "hsv"; + } + else if (color.hasOwnProperty("h") && color.hasOwnProperty("s") && color.hasOwnProperty("l")) { + color.s = convertToPercentage(color.s); + color.l = convertToPercentage(color.l); + rgb = hslToRgb(color.h, color.s, color.l); + ok = true; + format = "hsl"; + } + + if (color.hasOwnProperty("a")) { + a = color.a; + } + } + + a = boundAlpha(a); + + return { + ok: ok, + format: color.format || format, + r: mathMin(255, mathMax(rgb.r, 0)), + g: mathMin(255, mathMax(rgb.g, 0)), + b: mathMin(255, mathMax(rgb.b, 0)), + a: a + }; + } + + + // Conversion Functions + // -------------------- + + // `rgbToHsl`, `rgbToHsv`, `hslToRgb`, `hsvToRgb` modified from: + // <http://mjijackson.com/2008/02/rgb-to-hsl-and-rgb-to-hsv-color-model-conversion-algorithms-in-javascript> + + // `rgbToRgb` + // Handle bounds / percentage checking to conform to CSS color spec + // <http://www.w3.org/TR/css3-color/> + // *Assumes:* r, g, b in [0, 255] or [0, 1] + // *Returns:* { r, g, b } in [0, 255] + function rgbToRgb(r, g, b){ + return { + r: bound01(r, 255) * 255, + g: bound01(g, 255) * 255, + b: bound01(b, 255) * 255 + }; + } + + // `rgbToHsl` + // Converts an RGB color value to HSL. + // *Assumes:* r, g, and b are contained in [0, 255] or [0, 1] + // *Returns:* { h, s, l } in [0,1] + function rgbToHsl(r, g, b) { + + r = bound01(r, 255); + g = bound01(g, 255); + b = bound01(b, 255); + + var max = mathMax(r, g, b), min = mathMin(r, g, b); + var h, s, l = (max + min) / 2; + + if(max == min) { + h = s = 0; // achromatic + } + else { + var d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch(max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + + h /= 6; + } + + return { h: h, s: s, l: l }; + } + + // `hslToRgb` + // Converts an HSL color value to RGB. + // *Assumes:* h is contained in [0, 1] or [0, 360] and s and l are contained [0, 1] or [0, 100] + // *Returns:* { r, g, b } in the set [0, 255] + function hslToRgb(h, s, l) { + var r, g, b; + + h = bound01(h, 360); + s = bound01(s, 100); + l = bound01(l, 100); + + function hue2rgb(p, q, t) { + if(t < 0) t += 1; + if(t > 1) t -= 1; + if(t < 1/6) return p + (q - p) * 6 * t; + if(t < 1/2) return q; + if(t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + } + + if(s === 0) { + r = g = b = l; // achromatic + } + else { + var q = l < 0.5 ? l * (1 + s) : l + s - l * s; + var p = 2 * l - q; + r = hue2rgb(p, q, h + 1/3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1/3); + } + + return { r: r * 255, g: g * 255, b: b * 255 }; + } + + // `rgbToHsv` + // Converts an RGB color value to HSV + // *Assumes:* r, g, and b are contained in the set [0, 255] or [0, 1] + // *Returns:* { h, s, v } in [0,1] + function rgbToHsv(r, g, b) { + + r = bound01(r, 255); + g = bound01(g, 255); + b = bound01(b, 255); + + var max = mathMax(r, g, b), min = mathMin(r, g, b); + var h, s, v = max; + + var d = max - min; + s = max === 0 ? 0 : d / max; + + if(max == min) { + h = 0; // achromatic + } + else { + switch(max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + h /= 6; + } + return { h: h, s: s, v: v }; + } + + // `hsvToRgb` + // Converts an HSV color value to RGB. + // *Assumes:* h is contained in [0, 1] or [0, 360] and s and v are contained in [0, 1] or [0, 100] + // *Returns:* { r, g, b } in the set [0, 255] + function hsvToRgb(h, s, v) { + + h = bound01(h, 360) * 6; + s = bound01(s, 100); + v = bound01(v, 100); + + var i = math.floor(h), + f = h - i, + p = v * (1 - s), + q = v * (1 - f * s), + t = v * (1 - (1 - f) * s), + mod = i % 6, + r = [v, q, p, p, t, v][mod], + g = [t, v, v, q, p, p][mod], + b = [p, p, t, v, v, q][mod]; + + return { r: r * 255, g: g * 255, b: b * 255 }; + } + + // `rgbToHex` + // Converts an RGB color to hex + // Assumes r, g, and b are contained in the set [0, 255] + // Returns a 3 or 6 character hex + function rgbToHex(r, g, b, allow3Char) { + + var hex = [ + pad2(mathRound(r).toString(16)), + pad2(mathRound(g).toString(16)), + pad2(mathRound(b).toString(16)) + ]; + + // Return a 3 character hex if possible + if (allow3Char && hex[0].charAt(0) == hex[0].charAt(1) && hex[1].charAt(0) == hex[1].charAt(1) && hex[2].charAt(0) == hex[2].charAt(1)) { + return hex[0].charAt(0) + hex[1].charAt(0) + hex[2].charAt(0); + } + + return hex.join(""); + } + // `rgbaToHex` + // Converts an RGBA color plus alpha transparency to hex + // Assumes r, g, b and a are contained in the set [0, 255] + // Returns an 8 character hex + function rgbaToHex(r, g, b, a) { + + var hex = [ + pad2(convertDecimalToHex(a)), + pad2(mathRound(r).toString(16)), + pad2(mathRound(g).toString(16)), + pad2(mathRound(b).toString(16)) + ]; + + return hex.join(""); + } + + // `equals` + // Can be called with any tinycolor input + tinycolor.equals = function (color1, color2) { + if (!color1 || !color2) { return false; } + return tinycolor(color1).toRgbString() == tinycolor(color2).toRgbString(); + }; + tinycolor.random = function() { + return tinycolor.fromRatio({ + r: mathRandom(), + g: mathRandom(), + b: mathRandom() + }); + }; + + + // Modification Functions + // ---------------------- + // Thanks to less.js for some of the basics here + // <https://github.com/cloudhead/less.js/blob/master/lib/less/functions.js> + + function desaturate(color, amount) { + amount = (amount === 0) ? 0 : (amount || 10); + var hsl = tinycolor(color).toHsl(); + hsl.s -= amount / 100; + hsl.s = clamp01(hsl.s); + return tinycolor(hsl); + } + + function saturate(color, amount) { + amount = (amount === 0) ? 0 : (amount || 10); + var hsl = tinycolor(color).toHsl(); + hsl.s += amount / 100; + hsl.s = clamp01(hsl.s); + return tinycolor(hsl); + } + + function greyscale(color) { + return tinycolor(color).desaturate(100); + } + + function lighten (color, amount) { + amount = (amount === 0) ? 0 : (amount || 10); + var hsl = tinycolor(color).toHsl(); + hsl.l += amount / 100; + hsl.l = clamp01(hsl.l); + return tinycolor(hsl); + } + + function brighten(color, amount) { + amount = (amount === 0) ? 0 : (amount || 10); + var rgb = tinycolor(color).toRgb(); + rgb.r = mathMax(0, mathMin(255, rgb.r - mathRound(255 * - (amount / 100)))); + rgb.g = mathMax(0, mathMin(255, rgb.g - mathRound(255 * - (amount / 100)))); + rgb.b = mathMax(0, mathMin(255, rgb.b - mathRound(255 * - (amount / 100)))); + return tinycolor(rgb); + } + + function darken (color, amount) { + amount = (amount === 0) ? 0 : (amount || 10); + var hsl = tinycolor(color).toHsl(); + hsl.l -= amount / 100; + hsl.l = clamp01(hsl.l); + return tinycolor(hsl); + } + + // Spin takes a positive or negative amount within [-360, 360] indicating the change of hue. + // Values outside of this range will be wrapped into this range. + function spin(color, amount) { + var hsl = tinycolor(color).toHsl(); + var hue = (mathRound(hsl.h) + amount) % 360; + hsl.h = hue < 0 ? 360 + hue : hue; + return tinycolor(hsl); + } + + // Combination Functions + // --------------------- + // Thanks to jQuery xColor for some of the ideas behind these + // <https://github.com/infusion/jQuery-xcolor/blob/master/jquery.xcolor.js> + + function complement(color) { + var hsl = tinycolor(color).toHsl(); + hsl.h = (hsl.h + 180) % 360; + return tinycolor(hsl); + } + + function triad(color) { + var hsl = tinycolor(color).toHsl(); + var h = hsl.h; + return [ + tinycolor(color), + tinycolor({ h: (h + 120) % 360, s: hsl.s, l: hsl.l }), + tinycolor({ h: (h + 240) % 360, s: hsl.s, l: hsl.l }) + ]; + } + + function tetrad(color) { + var hsl = tinycolor(color).toHsl(); + var h = hsl.h; + return [ + tinycolor(color), + tinycolor({ h: (h + 90) % 360, s: hsl.s, l: hsl.l }), + tinycolor({ h: (h + 180) % 360, s: hsl.s, l: hsl.l }), + tinycolor({ h: (h + 270) % 360, s: hsl.s, l: hsl.l }) + ]; + } + + function splitcomplement(color) { + var hsl = tinycolor(color).toHsl(); + var h = hsl.h; + return [ + tinycolor(color), + tinycolor({ h: (h + 72) % 360, s: hsl.s, l: hsl.l}), + tinycolor({ h: (h + 216) % 360, s: hsl.s, l: hsl.l}) + ]; + } + + function analogous(color, results, slices) { + results = results || 6; + slices = slices || 30; + + var hsl = tinycolor(color).toHsl(); + var part = 360 / slices; + var ret = [tinycolor(color)]; + + for (hsl.h = ((hsl.h - (part * results >> 1)) + 720) % 360; --results; ) { + hsl.h = (hsl.h + part) % 360; + ret.push(tinycolor(hsl)); + } + return ret; + } + + function monochromatic(color, results) { + results = results || 6; + var hsv = tinycolor(color).toHsv(); + var h = hsv.h, s = hsv.s, v = hsv.v; + var ret = []; + var modification = 1 / results; + + while (results--) { + ret.push(tinycolor({ h: h, s: s, v: v})); + v = (v + modification) % 1; + } + + return ret; + } + + // Utility Functions + // --------------------- + + tinycolor.mix = function(color1, color2, amount) { + amount = (amount === 0) ? 0 : (amount || 50); + + var rgb1 = tinycolor(color1).toRgb(); + var rgb2 = tinycolor(color2).toRgb(); + + var p = amount / 100; + var w = p * 2 - 1; + var a = rgb2.a - rgb1.a; + + var w1; + + if (w * a == -1) { + w1 = w; + } else { + w1 = (w + a) / (1 + w * a); + } + + w1 = (w1 + 1) / 2; + + var w2 = 1 - w1; + + var rgba = { + r: rgb2.r * w1 + rgb1.r * w2, + g: rgb2.g * w1 + rgb1.g * w2, + b: rgb2.b * w1 + rgb1.b * w2, + a: rgb2.a * p + rgb1.a * (1 - p) + }; + + return tinycolor(rgba); + }; + + + // Readability Functions + // --------------------- + // <http://www.w3.org/TR/AERT#color-contrast> + + // `readability` + // Analyze the 2 colors and returns an object with the following properties: + // `brightness`: difference in brightness between the two colors + // `color`: difference in color/hue between the two colors + tinycolor.readability = function(color1, color2) { + var c1 = tinycolor(color1); + var c2 = tinycolor(color2); + var rgb1 = c1.toRgb(); + var rgb2 = c2.toRgb(); + var brightnessA = c1.getBrightness(); + var brightnessB = c2.getBrightness(); + var colorDiff = ( + Math.max(rgb1.r, rgb2.r) - Math.min(rgb1.r, rgb2.r) + + Math.max(rgb1.g, rgb2.g) - Math.min(rgb1.g, rgb2.g) + + Math.max(rgb1.b, rgb2.b) - Math.min(rgb1.b, rgb2.b) + ); + + return { + brightness: Math.abs(brightnessA - brightnessB), + color: colorDiff + }; + }; + + // `readable` + // http://www.w3.org/TR/AERT#color-contrast + // Ensure that foreground and background color combinations provide sufficient contrast. + // *Example* + // tinycolor.isReadable("#000", "#111") => false + tinycolor.isReadable = function(color1, color2) { + var readability = tinycolor.readability(color1, color2); + return readability.brightness > 125 && readability.color > 500; + }; + + // `mostReadable` + // Given a base color and a list of possible foreground or background + // colors for that base, returns the most readable color. + // *Example* + // tinycolor.mostReadable("#123", ["#fff", "#000"]) => "#000" + tinycolor.mostReadable = function(baseColor, colorList) { + var bestColor = null; + var bestScore = 0; + var bestIsReadable = false; + for (var i=0; i < colorList.length; i++) { + + // We normalize both around the "acceptable" breaking point, + // but rank brightness constrast higher than hue. + + var readability = tinycolor.readability(baseColor, colorList[i]); + var readable = readability.brightness > 125 && readability.color > 500; + var score = 3 * (readability.brightness / 125) + (readability.color / 500); + + if ((readable && ! bestIsReadable) || + (readable && bestIsReadable && score > bestScore) || + ((! readable) && (! bestIsReadable) && score > bestScore)) { + bestIsReadable = readable; + bestScore = score; + bestColor = tinycolor(colorList[i]); + } + } + return bestColor; + }; + + + // Big List of Colors + // ------------------ + // <http://www.w3.org/TR/css3-color/#svg-color> + var names = tinycolor.names = { + aliceblue: "f0f8ff", + antiquewhite: "faebd7", + aqua: "0ff", + aquamarine: "7fffd4", + azure: "f0ffff", + beige: "f5f5dc", + bisque: "ffe4c4", + black: "000", + blanchedalmond: "ffebcd", + blue: "00f", + blueviolet: "8a2be2", + brown: "a52a2a", + burlywood: "deb887", + burntsienna: "ea7e5d", + cadetblue: "5f9ea0", + chartreuse: "7fff00", + chocolate: "d2691e", + coral: "ff7f50", + cornflowerblue: "6495ed", + cornsilk: "fff8dc", + crimson: "dc143c", + cyan: "0ff", + darkblue: "00008b", + darkcyan: "008b8b", + darkgoldenrod: "b8860b", + darkgray: "a9a9a9", + darkgreen: "006400", + darkgrey: "a9a9a9", + darkkhaki: "bdb76b", + darkmagenta: "8b008b", + darkolivegreen: "556b2f", + darkorange: "ff8c00", + darkorchid: "9932cc", + darkred: "8b0000", + darksalmon: "e9967a", + darkseagreen: "8fbc8f", + darkslateblue: "483d8b", + darkslategray: "2f4f4f", + darkslategrey: "2f4f4f", + darkturquoise: "00ced1", + darkviolet: "9400d3", + deeppink: "ff1493", + deepskyblue: "00bfff", + dimgray: "696969", + dimgrey: "696969", + dodgerblue: "1e90ff", + firebrick: "b22222", + floralwhite: "fffaf0", + forestgreen: "228b22", + fuchsia: "f0f", + gainsboro: "dcdcdc", + ghostwhite: "f8f8ff", + gold: "ffd700", + goldenrod: "daa520", + gray: "808080", + green: "008000", + greenyellow: "adff2f", + grey: "808080", + honeydew: "f0fff0", + hotpink: "ff69b4", + indianred: "cd5c5c", + indigo: "4b0082", + ivory: "fffff0", + khaki: "f0e68c", + lavender: "e6e6fa", + lavenderblush: "fff0f5", + lawngreen: "7cfc00", + lemonchiffon: "fffacd", + lightblue: "add8e6", + lightcoral: "f08080", + lightcyan: "e0ffff", + lightgoldenrodyellow: "fafad2", + lightgray: "d3d3d3", + lightgreen: "90ee90", + lightgrey: "d3d3d3", + lightpink: "ffb6c1", + lightsalmon: "ffa07a", + lightseagreen: "20b2aa", + lightskyblue: "87cefa", + lightslategray: "789", + lightslategrey: "789", + lightsteelblue: "b0c4de", + lightyellow: "ffffe0", + lime: "0f0", + limegreen: "32cd32", + linen: "faf0e6", + magenta: "f0f", + maroon: "800000", + mediumaquamarine: "66cdaa", + mediumblue: "0000cd", + mediumorchid: "ba55d3", + mediumpurple: "9370db", + mediumseagreen: "3cb371", + mediumslateblue: "7b68ee", + mediumspringgreen: "00fa9a", + mediumturquoise: "48d1cc", + mediumvioletred: "c71585", + midnightblue: "191970", + mintcream: "f5fffa", + mistyrose: "ffe4e1", + moccasin: "ffe4b5", + navajowhite: "ffdead", + navy: "000080", + oldlace: "fdf5e6", + olive: "808000", + olivedrab: "6b8e23", + orange: "ffa500", + orangered: "ff4500", + orchid: "da70d6", + palegoldenrod: "eee8aa", + palegreen: "98fb98", + paleturquoise: "afeeee", + palevioletred: "db7093", + papayawhip: "ffefd5", + peachpuff: "ffdab9", + peru: "cd853f", + pink: "ffc0cb", + plum: "dda0dd", + powderblue: "b0e0e6", + purple: "800080", + rebeccapurple: "663399", + red: "f00", + rosybrown: "bc8f8f", + royalblue: "4169e1", + saddlebrown: "8b4513", + salmon: "fa8072", + sandybrown: "f4a460", + seagreen: "2e8b57", + seashell: "fff5ee", + sienna: "a0522d", + silver: "c0c0c0", + skyblue: "87ceeb", + slateblue: "6a5acd", + slategray: "708090", + slategrey: "708090", + snow: "fffafa", + springgreen: "00ff7f", + steelblue: "4682b4", + tan: "d2b48c", + teal: "008080", + thistle: "d8bfd8", + tomato: "ff6347", + turquoise: "40e0d0", + violet: "ee82ee", + wheat: "f5deb3", + white: "fff", + whitesmoke: "f5f5f5", + yellow: "ff0", + yellowgreen: "9acd32" + }; + + // Make it easy to access colors via `hexNames[hex]` + var hexNames = tinycolor.hexNames = flip(names); + + + // Utilities + // --------- + + // `{ 'name1': 'val1' }` becomes `{ 'val1': 'name1' }` + function flip(o) { + var flipped = { }; + for (var i in o) { + if (o.hasOwnProperty(i)) { + flipped[o[i]] = i; + } + } + return flipped; + } + + // Return a valid alpha value [0,1] with all invalid values being set to 1 + function boundAlpha(a) { + a = parseFloat(a); + + if (isNaN(a) || a < 0 || a > 1) { + a = 1; + } + + return a; + } + + // Take input from [0, n] and return it as [0, 1] + function bound01(n, max) { + if (isOnePointZero(n)) { n = "100%"; } + + var processPercent = isPercentage(n); + n = mathMin(max, mathMax(0, parseFloat(n))); + + // Automatically convert percentage into number + if (processPercent) { + n = parseInt(n * max, 10) / 100; + } + + // Handle floating point rounding errors + if ((math.abs(n - max) < 0.000001)) { + return 1; + } + + // Convert into [0, 1] range if it isn't already + return (n % max) / parseFloat(max); + } + + // Force a number between 0 and 1 + function clamp01(val) { + return mathMin(1, mathMax(0, val)); + } + + // Parse a base-16 hex value into a base-10 integer + function parseIntFromHex(val) { + return parseInt(val, 16); + } + + // Need to handle 1.0 as 100%, since once it is a number, there is no difference between it and 1 + // <http://stackoverflow.com/questions/7422072/javascript-how-to-detect-number-as-a-decimal-including-1-0> + function isOnePointZero(n) { + return typeof n == "string" && n.indexOf('.') != -1 && parseFloat(n) === 1; + } + + // Check to see if string passed in is a percentage + function isPercentage(n) { + return typeof n === "string" && n.indexOf('%') != -1; + } + + // Force a hex value to have 2 characters + function pad2(c) { + return c.length == 1 ? '0' + c : '' + c; + } + + // Replace a decimal with it's percentage value + function convertToPercentage(n) { + if (n <= 1) { + n = (n * 100) + "%"; + } + + return n; + } + + // Converts a decimal to a hex value + function convertDecimalToHex(d) { + return Math.round(parseFloat(d) * 255).toString(16); + } + // Converts a hex value to a decimal + function convertHexToDecimal(h) { + return (parseIntFromHex(h) / 255); + } + + var matchers = (function() { + + // <http://www.w3.org/TR/css3-values/#integers> + var CSS_INTEGER = "[-\\+]?\\d+%?"; + + // <http://www.w3.org/TR/css3-values/#number-value> + var CSS_NUMBER = "[-\\+]?\\d*\\.\\d+%?"; + + // Allow positive/negative integer/number. Don't capture the either/or, just the entire outcome. + var CSS_UNIT = "(?:" + CSS_NUMBER + ")|(?:" + CSS_INTEGER + ")"; + + // Actual matching. + // Parentheses and commas are optional, but not required. + // Whitespace can take the place of commas or opening paren + var PERMISSIVE_MATCH3 = "[\\s|\\(]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")\\s*\\)?"; + var PERMISSIVE_MATCH4 = "[\\s|\\(]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")\\s*\\)?"; + + return { + rgb: new RegExp("rgb" + PERMISSIVE_MATCH3), + rgba: new RegExp("rgba" + PERMISSIVE_MATCH4), + hsl: new RegExp("hsl" + PERMISSIVE_MATCH3), + hsla: new RegExp("hsla" + PERMISSIVE_MATCH4), + hsv: new RegExp("hsv" + PERMISSIVE_MATCH3), + hsva: new RegExp("hsva" + PERMISSIVE_MATCH4), + hex3: /^([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/, + hex6: /^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/, + hex8: /^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/ + }; + })(); + + // `stringInputToObject` + // Permissive string parsing. Take in a number of formats, and output an object + // based on detected format. Returns `{ r, g, b }` or `{ h, s, l }` or `{ h, s, v}` + function stringInputToObject(color) { + + color = color.replace(trimLeft,'').replace(trimRight, '').toLowerCase(); + var named = false; + if (names[color]) { + color = names[color]; + named = true; + } + else if (color == 'transparent') { + return { r: 0, g: 0, b: 0, a: 0, format: "name" }; + } + + // Try to match string input using regular expressions. + // Keep most of the number bounding out of this function - don't worry about [0,1] or [0,100] or [0,360] + // Just return an object and let the conversion functions handle that. + // This way the result will be the same whether the tinycolor is initialized with string or object. + var match; + if ((match = matchers.rgb.exec(color))) { + return { r: match[1], g: match[2], b: match[3] }; + } + if ((match = matchers.rgba.exec(color))) { + return { r: match[1], g: match[2], b: match[3], a: match[4] }; + } + if ((match = matchers.hsl.exec(color))) { + return { h: match[1], s: match[2], l: match[3] }; + } + if ((match = matchers.hsla.exec(color))) { + return { h: match[1], s: match[2], l: match[3], a: match[4] }; + } + if ((match = matchers.hsv.exec(color))) { + return { h: match[1], s: match[2], v: match[3] }; + } + if ((match = matchers.hsva.exec(color))) { + return { h: match[1], s: match[2], v: match[3], a: match[4] }; + } + if ((match = matchers.hex8.exec(color))) { + return { + a: convertHexToDecimal(match[1]), + r: parseIntFromHex(match[2]), + g: parseIntFromHex(match[3]), + b: parseIntFromHex(match[4]), + format: named ? "name" : "hex8" + }; + } + if ((match = matchers.hex6.exec(color))) { + return { + r: parseIntFromHex(match[1]), + g: parseIntFromHex(match[2]), + b: parseIntFromHex(match[3]), + format: named ? "name" : "hex" + }; + } + if ((match = matchers.hex3.exec(color))) { + return { + r: parseIntFromHex(match[1] + '' + match[1]), + g: parseIntFromHex(match[2] + '' + match[2]), + b: parseIntFromHex(match[3] + '' + match[3]), + format: named ? "name" : "hex" + }; + } + + return false; + } + + window.tinycolor = tinycolor; + })(); + + $(function () { + if ($.fn.spectrum.load) { + $.fn.spectrum.processNativeColorInputs(); + } + }); + +}); diff --git a/scp/queues.php b/scp/queues.php new file mode 100644 index 0000000000000000000000000000000000000000..d348d570503e54c51dd17358dc0421645ceb7e5c --- /dev/null +++ b/scp/queues.php @@ -0,0 +1,26 @@ +<?php +/********************************************************************* + queues.php + + Handles management of custom queues + + Jared Hancock <jared@osticket.com> + Peter Rotich <peter@osticket.com> + Copyright (c) 2006-2015 osTicket + http://www.osticket.com + + Released under the GNU General Public License WITHOUT ANY WARRANTY. + See LICENSE.TXT for details. + + vim: expandtab sw=4 ts=4 sts=4: +**********************************************************************/ + +require('admin.inc.php'); + +require_once INCLUDE_DIR . 'class.queue.php'; + +$nav->setTabActive('settings', 'settings.php?t='.urlencode($_GET['t'])); + +require_once(STAFFINC_DIR.'header.inc.php'); +include_once(STAFFINC_DIR."queue.inc.php"); +include_once(STAFFINC_DIR.'footer.inc.php');