diff --git a/include/class.forms.php b/include/class.forms.php index 55f62a66b90da822ddec04e831f92488b951c6a2..3c8c2bab55219e1d4db2a313a883fbbac2dfe665 100644 --- a/include/class.forms.php +++ b/include/class.forms.php @@ -469,8 +469,9 @@ class FormField { # Validates a user-input into an instance of this field on a dynamic # form if ($this->get('required') && !$value && $this->hasData()) - $this->_errors[] = sprintf(__('%s is a required field'), - $this->getLabel()); + $this->_errors[] = $this->getLabel() + ? sprintf(__('%s is a required field'), $this->getLabel()) + : __('This is a required field'); # Perform declared validators for the field if ($vs = $this->get('validators')) { @@ -685,9 +686,9 @@ class FormField { function getSearchMethods() { return array( 'set' => __('has a value'), - 'notset' => __('does not have a value'), + 'nset' => __('does not have a value'), 'equal' => __('is'), - 'equal.not' => __('is not'), + 'nequal' => __('is not'), 'contains' => __('contains'), 'match' => __('matches'), ); @@ -696,9 +697,9 @@ class FormField { function getSearchMethodWidgets() { return array( 'set' => null, - 'notset' => null, + 'nset' => null, 'equal' => array('TextboxField', array()), - 'equal.not' => array('TextboxField', array()), + 'nequal' => array('TextboxField', array()), 'contains' => array('TextboxField', array()), 'match' => array('TextboxField', array( 'placeholder' => __('Valid regular expression'), @@ -720,13 +721,13 @@ class FormField { $Q = new Q(); $name = $name ?: $this->get('name'); switch ($method) { - case 'notset': + case 'nset': $Q->negate(); case 'set': $criteria[$name . '__isnull'] = false; break; - case 'equal.not': + case 'nequal': $Q->negate(); case 'equal': $criteria[$name . '__eq'] = $value; @@ -753,6 +754,28 @@ class FormField { return $info; } + function describeSearchMethod($method) { + switch ($method) { + case 'set': + return __('%s has a value'); + case 'nset': + return __('%s does not have a value'); + case 'equal': + return __('%s is %s' /* describes an equality */); + case 'nequal': + return __('%s is not %s' /* describes an inequality */); + case 'contains': + return __('%s contains "%s"'); + case 'match': + return __('%s matches pattern %s'); + } + } + function describeSearch($method, $value, $name=false) { + $desc = $this->describeSearchMethod($method); + $value = $this->toString($value); + return sprintf($desc, $name, $value); + } + function getLabel() { return $this->get('label'); } /** @@ -1254,14 +1277,14 @@ class BooleanField extends FormField { function getSearchMethods() { return array( 'set' => __('checked'), - 'set.not' => __('unchecked'), + 'nset' => __('unchecked'), ); } function getSearchMethodWidgets() { return array( 'set' => null, - 'set.not' => null, + 'nset' => null, ); } @@ -1270,7 +1293,7 @@ class BooleanField extends FormField { switch ($method) { case 'set': return new Q(array($name => '1')); - case 'set.not': + case 'nset': return new Q(array($name => '0')); default: return parent::getSearchQ($method, $value, $name); @@ -1452,7 +1475,7 @@ class ChoiceField extends FormField { function getSearchMethods() { return array( 'set' => __('has a value'), - 'notset' => __('does not have a value'), + 'nset' => __('does not have a value'), 'includes' => __('includes'), '!includes' => __('does not include'), ); @@ -1461,7 +1484,7 @@ class ChoiceField extends FormField { function getSearchMethodWidgets() { return array( 'set' => null, - 'notset' => null, + 'nset' => null, 'includes' => array('ChoiceField', array( 'choices' => $this->getChoices(), 'configuration' => array('multiselect' => true), @@ -1484,6 +1507,17 @@ class ChoiceField extends FormField { return parent::getSearchQ($method, $value, $name); } } + + function describeSearchMethod($method) { + switch ($method) { + case 'includes': + return __('%s includes %s' /* includes -> if a list includes a selection */); + case 'includes': + return __('%s does not include %s' /* includes -> if a list includes a selection */); + default: + return parent::describeSearchMethod($method); + } + } } class DatetimeField extends FormField { @@ -1565,12 +1599,13 @@ class DatetimeField extends FormField { $this->_errors[] = __('Enter a valid date'); } + // SearchableField interface ------------------------------ function getSearchMethods() { return array( 'set' => __('has a value'), - 'notset' => __('does not have a value'), + 'nset' => __('does not have a value'), 'equal' => __('on'), - 'notequal' => __('not on'), + 'nequal' => __('not on'), 'before' => __('before'), 'after' => __('after'), 'between' => __('between'), @@ -1584,11 +1619,11 @@ class DatetimeField extends FormField { $config_notime['time'] = false; return array( 'set' => null, - 'notset' => null, + 'nset' => null, 'equal' => array('DatetimeField', array( 'configuration' => $config_notime, )), - 'notequal' => array('DatetimeField', array( + 'nequal' => array('DatetimeField', array( 'configuration' => $config_notime, )), 'before' => array('DatetimeField', array( @@ -1631,7 +1666,24 @@ class DatetimeField extends FormField { function getSearchQ($method, $value, $name=false) { $name = $name ?: $this->get('name'); + $value = is_int($value) + ? DateTime::createFromFormat('U', Misc::dbtime($value)) ?: $value + : $value; switch ($method) { + case 'equal': + $l = clone $value; + $r = $value->add(new DateInterval('P1D')); + return new Q(array( + "{$name}__gte" => $l, + "{$name}__lt" => $r + )); + case 'nequal': + $l = clone $value; + $r = $value->add(new DateInterval('P1D')); + return Q::any(array( + "{$name}__lt" => $l, + "{$name}__gte" => $r, + )); case 'after': return new Q(array("{$name}__gte" => $value)); case 'before': @@ -1655,6 +1707,33 @@ class DatetimeField extends FormField { return parent::getSearchQ($method, $value, $name); } } + + function describeSearchMethod($method) { + switch ($method) { + case 'before': + return __('%1$s before %2$s' /* occurs before a date and time */); + case 'after': + return __('%1$s after %2$s' /* occurs after a date and time */); + case 'ndays': + return __('%1$s in the next %2$s' /* occurs within a window (like 3 days) */); + case 'ndaysago': + return __('%1$s in the last %2$s' /* occurs within a recent window (like 3 days) */); + case 'between': + return __('%1$s between %2$s and %3$s'); + default: + return parent::describeSearchMethod($method); + } + } + + function describeSearch($method, $value, $name=false) { + if ($method === 'between') { + $l = $this->toString($value['left']); + $r = $this->toString($value['right']); + $desc = $this->describeSearchMethod($method); + return sprintf($desc, $name, $l, $r); + } + return parent::describeSearch($method, $value, $name); + } } /** @@ -2944,10 +3023,9 @@ class CheckboxWidget extends Widget { if ($this->value) echo 'checked="checked"'; ?> value="<?php echo $this->field->get('id'); ?>"/> <?php - if ($config['desc']) { ?> - <em style="display:inline-block"><?php - echo Format::viewableImages($config['desc']); ?></em> - <?php } + if ($config['desc']) { + echo Format::viewableImages($config['desc']); + } } function getValue() { diff --git a/include/class.misc.php b/include/class.misc.php index d0a98e1fa4730df7525c3b39f75c6e720f530ac7..4a7301782600c3cfe69b8b493d3ee02794bda8a8 100644 --- a/include/class.misc.php +++ b/include/class.misc.php @@ -99,7 +99,8 @@ class Misc { $dbtz = new DateTimeZone($cfg->getDbTimezone()); } // UTC to db time - return $time + $dbtz->getOffset($time); + $D = DateTime::createFromFormat('U', $time); + return $time + $dbtz->getOffset($D); } /*Helper get GM time based on timezone offset*/ diff --git a/include/class.orm.php b/include/class.orm.php index 516d714da548fe9e5d502b444bfd56454b09a401..b0aa78eaac87d714b708be1e2e541cf60f996aef 100644 --- a/include/class.orm.php +++ b/include/class.orm.php @@ -796,11 +796,17 @@ class SqlInterval extends SqlFunction { } class SqlField extends SqlFunction { - function __construct($field) { + var $level; + + function __construct($field, $level=0) { $this->field = $field; + $this->level = $level; } function toSql($compiler, $model=false, $alias=false) { + $L = $this->level; + while ($L--) + $compiler = $compiler->getParent(); list($field) = $compiler->getField($this->field, $model); return $field; } @@ -1038,7 +1044,6 @@ class QuerySet implements IteratorAggregate, ArrayAccess, Serializable, Countabl .'multiple objects in the database matched the query. ' .sprintf('In fact, there are %d matching objects.', count($list)) ); - // TODO: Throw error if more than one result from database return $list[0]; } @@ -1057,10 +1062,11 @@ class QuerySet implements IteratorAggregate, ArrayAccess, Serializable, Countabl function toSql($compiler, $model, $alias) { // FIXME: Force root model of the compiler to $model - $exec = $this->getQuery(array('compiler' => get_class($compiler))); + $exec = $this->getQuery(array('compiler' => get_class($compiler), + 'parent' => $compiler, 'subquery' => true)); foreach ($exec->params as $P) $compiler->params[] = $P; - return "({$exec})".($alias ? " AS {$alias}" : ''); + return "({$exec->sql})".($alias ? " AS {$alias}" : ''); } /** @@ -1600,6 +1606,12 @@ class SqlCompiler { function __construct($options=false) { if ($options) $this->options = array_merge($this->options, $options); + if ($options['subquery']) + $this->alias_num += 150; + } + + function getParent() { + return $this->options['parent']; } /** @@ -2067,16 +2079,15 @@ class MySqlCompiler extends SqlCompiler { /** * input * - * Generate a parameterized input for a database query. Input value is - * received by reference to avoid copying. + * Generate a parameterized input for a database query. * * Parameters: * $what - (mixed) value to be sent to the database. No escaping is * necessary. Pass a raw value here. * * Returns: - * (string) token to be placed into the compiled SQL statement. For - * MySQL, this is always the string '?'. + * (string) token to be placed into the compiled SQL statement. This + * is a colon followed by a number */ function input($what, $slot=false) { if ($what instanceof QuerySet) { @@ -2426,7 +2437,8 @@ class MySqlExecutor { function fixupParams() { $self = $this; $params = array(); - $sql = preg_replace_callback('/:(\d+)/', function($m) use ($self, &$params) { + $sql = preg_replace_callback("/:(\d+)(?=([^']*'[^']*')*[^']*$)/", + function($m) use ($self, &$params) { $params[] = $self->params[$m[1]-1]; return '?'; }, $this->sql); @@ -2466,7 +2478,7 @@ class MySqlExecutor { $types .= 's'; elseif ($p instanceof DateTime) { $types .= 's'; - $p = $p->format('Y-m-d h:i:s'); + $p = $p->format('Y-m-d H:i:s'); } elseif (is_object($p)) { $types .= 's'; @@ -2559,8 +2571,12 @@ class MySqlExecutor { function __toString() { $self = $this; - return preg_replace_callback('/:(\d+)/', function($m) use ($self) { + return preg_replace_callback("/:(\d+)(?=([^']*'[^']*')*[^']*$)/", + function($m) use ($self) { $p = $self->params[$m[1]-1]; + if ($p instanceof DateTime) { + $p = $p->format('Y-m-d H:i:s'); + } return db_real_escape($p, is_string($p)); }, $this->sql); } diff --git a/include/class.search.php b/include/class.search.php index b68a0d3ed37c6cf92e712987c141c36e4ff0f378..109d67a9c9f7b766e46b032cdd3d6f37d87738e3 100644 --- a/include/class.search.php +++ b/include/class.search.php @@ -706,7 +706,7 @@ class SavedSearch extends VerySimpleModel { 'id' => 3105, 'label' => __('Created'), )), - 'duedate' => new DateTimeField(array( + 'est_duedate' => new DateTimeField(array( 'id' => 3106, 'label' => __('Due Date'), )), @@ -785,6 +785,7 @@ class SavedSearch extends VerySimpleModel { list($class, $args) = $w; $args['id'] = $baseId + 50002 + $offs++; $args['required'] = true; + $args['__searchval__'] = true; $args['visibility'] = new VisibilityConstraint(new Q(array( "{$name}+method__eq" => $m, )), VisibilityConstraint::HIDDEN); @@ -792,17 +793,23 @@ class SavedSearch extends VerySimpleModel { } return $pieces; } - - function mangleQuerySet(QuerySet $qs, $form=false) { + + /** + * Collect information on the search form. + * + * Returns: + * (<array(name => array('field' => <FormField>, 'method' => <string>, + * 'value' => <mixed>, 'active' => <bool>))>), which will help to + * explain each field active in the search form. + */ + function getSearchFields($form=false) { $form = $form ?: $this->getForm(); $searchable = $this->getCurrentSearchFields($form->state); - $qs = clone $qs; - - // Figure out fields to search on + $info = array(); foreach ($form->getFields() as $f) { - if (substr($f->get('name'), -7) == '+search' && $f->getClean()) { + if (substr($f->get('name'), -7) == '+search') { $name = substr($f->get('name'), 0, -7); - $filter = new Q(); + $value = null; // Determine the search method and fetch the original field if (($M = $form->getField("{$name}+method")) && ($method = $M->getClean()) @@ -810,49 +817,76 @@ class SavedSearch extends VerySimpleModel { ) { // Request the field to generate a search Q for the // search method and given value - $value = null; if ($value = $form->getField("{$name}+{$method}")) $value = $value->getClean(); + } + $info[$name] = array( + 'field' => $field, + 'method' => $method, + 'value' => $value, + 'active' => $f->getClean(), + ); + } + } + return $info; + } + + /** + * Get a description of a field in a search. Expects an entry from the + * array retrieved in ::getSearchFields() + */ + function describeField($info, $name=false) { + return $info['field']->describeSearch($info['method'], $info['value'], $name); + } - if ($name[0] == ':') { - // This was an 'other' field, fetch a special "name" - // for it which will be the ORM join path - static $other_paths = array( - ':ticket' => 'cdata__', - ':user' => 'user__cdata__', - ':organization' => 'user__org__cdata__', - ); - $column = $field->get('name') ?: 'field_'.$field->get('id'); - list($type,$id) = explode('!', $name, 2); - // XXX: Last mile — find a better idea - switch (array($type, $column)) { - case array(':user', 'name'): - $name = 'user__name'; - break; - case array(':user', 'email'): - $name = 'user__emails__address'; - break; - case array(':organization', 'name'): - $name = 'user__org__name'; - break; - default: - if ($type == ':field' && $id) { - $name = 'entries__answers__value'; - $filter->add(array('entries__answers__field_id' => $id)); - break; - } - $OP = $other_paths[$type]; - $name = $OP . $column; - } - } - - // Add the criteria to the QuerySet - if ($Q = $field->getSearchQ($method, $value, $name)) { - $filter->add($Q); - $qs = $qs->filter($filter); + function mangleQuerySet(QuerySet $qs, $form=false) { + $form = $form ?: $this->getForm(); + $searchable = $this->getCurrentSearchFields($form->state); + $qs = clone $qs; + + // Figure out fields to search on + foreach ($this->getSearchFields($form) as $name=>$info) { + if (!$info['active']) + continue; + $field = $info['field']; + $filter = new Q(); + if ($name[0] == ':') { + // This was an 'other' field, fetch a special "name" + // for it which will be the ORM join path + static $other_paths = array( + ':ticket' => 'cdata__', + ':user' => 'user__cdata__', + ':organization' => 'user__org__cdata__', + ); + $column = $field->get('name') ?: 'field_'.$field->get('id'); + list($type,$id) = explode('!', $name, 2); + // XXX: Last mile — find a better idea + switch (array($type, $column)) { + case array(':user', 'name'): + $name = 'user__name'; + break; + case array(':user', 'email'): + $name = 'user__emails__address'; + break; + case array(':organization', 'name'): + $name = 'user__org__name'; + break; + default: + if ($type == ':field' && $id) { + $name = 'entries__answers__value'; + $filter->add(array('entries__answers__field_id' => $id)); + break; } + $OP = $other_paths[$type]; + $name = $OP . $column; } } + + // Add the criteria to the QuerySet + if ($Q = $field->getSearchQ($info['method'], $info['value'], $name)) { + $filter->add($Q); + $qs = $qs->filter($filter); + } } // Consider keyword searching diff --git a/include/staff/templates/advanced-search-field.tmpl.php b/include/staff/templates/advanced-search-field.tmpl.php index 5191a0cf4dac8217e664798d76d925e4623e6e08..12086ceb9b63533f3f1bdb36aa8b3ad01c8eddd8 100644 --- a/include/staff/templates/advanced-search-field.tmpl.php +++ b/include/staff/templates/advanced-search-field.tmpl.php @@ -1,8 +1,19 @@ <input type="hidden" name="fields[]" value="<?php echo $name; ?>"/> <?php foreach ($fields as $F) { ?> <fieldset id="field<?php echo $F->getWidget()->id; - ?>" <?php if (!$F->isVisible()) echo 'style="display:none;"'; ?> - <?php if (substr($F->get('name'), -7) === '+search') echo 'class="advanced-search-field"'; ?>> + ?>" <?php + $class = array(); + @list($name, $sub) = explode('+', $F->get('name'), 2); + if (!$F->isVisible()) $class[] = "hidden"; + if ($sub === 'method') + $class[] = "adv-search-method"; + elseif ($sub === 'search') + $class[] = "adv-search-field"; + 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 diff --git a/include/staff/templates/advanced-search.tmpl.php b/include/staff/templates/advanced-search.tmpl.php index 94d193b598e851fff60469cadcf5e2706b23c32c..42779cd60cb99fea31525853fe10e13c221dfe25 100644 --- a/include/staff/templates/advanced-search.tmpl.php +++ b/include/staff/templates/advanced-search.tmpl.php @@ -1,6 +1,3 @@ -<?php - $ff_uid = FormField::$uid; -?> <div id="advanced-search"> <h3 class="drag-handle"><?php echo __('Advanced Ticket Search');?></h3> <a class="close" href=""><i class="icon-remove-circle"></i></a> @@ -14,11 +11,55 @@ foreach ($form->errors(true) ?: array() as $message) { ?><div class="error-banner"><?php echo $message;?></div><?php } -foreach ($form->getFields() as $name=>$field) { ?> - <fieldset id="field<?php echo $field->getWidget()->id; - ?>" <?php if (!$field->isVisible()) echo 'class="hidden"'; ?> - <?php if (substr($field->get('name'), -7) === '+search') echo 'class="advanced-search-field"'; ?>> +$info = $search->getSearchFields($form); +$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 } ?> @@ -29,6 +70,8 @@ foreach ($form->getFields() as $name=>$field) { ?> <input type="hidden" name="fields[]" value="<?php echo $N; ?>"/> <?php } } +if (!$first_field) + echo '</div></div>'; ?> <div id="extra-fields"></div> <hr/> @@ -101,7 +144,8 @@ return false; <hr> <form method="post"> <fieldset> - <input name="title" type="text" size="30" placeholder="Enter a title for the search"/> + <input name="title" type="text" size="27" placeholder="<?php + echo __('Enter a title for the search'); ?>"/> <span class="action-button"> <a href="#tickets/search/create" onclick="javascript: $.ajax({ @@ -163,13 +207,11 @@ $(function() { }); }, 200); - var ff_uid = <?php echo $ff_uid; ?>; $('#search-add-new-field').on('change', function() { var that=this; $.ajax({ url: 'ajax.php/tickets/search/field/'+$(this).val(), type: 'get', - data: {ff_uid: ff_uid}, dataType: 'json', success: function(json) { if (!json.success) diff --git a/include/staff/tickets.inc.php b/include/staff/tickets.inc.php index 32d556b3f7bb66191337103b82322ac45a834842..6c74947473fcdbc92390ac242538a6ff93cf36a0 100644 --- a/include/staff/tickets.inc.php +++ b/include/staff/tickets.inc.php @@ -290,15 +290,15 @@ $tickets->values('lock__staff_id', 'staff_id', 'isoverdue', 'team_id', 'ticket_i // Add in annotations $tickets->annotate(array( 'collab_count' => TicketThread::objects() - ->filter(array('ticket__ticket_id' => new SqlField('ticket_id'))) + ->filter(array('ticket__ticket_id' => new SqlField('ticket_id', 1))) ->aggregate(array('count' => SqlAggregate::COUNT('collaborators__id'))), 'attachment_count' => TicketThread::objects() - ->filter(array('ticket__ticket_id' => new SqlField('ticket_id'))) + ->filter(array('ticket__ticket_id' => new SqlField('ticket_id', 1))) ->filter(array('entries__attachments__inline' => 0)) ->aggregate(array('count' => SqlAggregate::COUNT('entries__attachments__id'))), 'thread_count' => TicketThread::objects() - ->filter(array('ticket__ticket_id' => new SqlField('ticket_id'))) - ->filter(Q::not(array('entries__flags__hasbit' => ThreadEntry::FLAG_HIDDEN))) + ->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'))), )); diff --git a/scp/css/scp.css b/scp/css/scp.css index 2015ba20d0cf0cdacdfb0c14d5eb18aeaebf80ff..88df4b16599867216d074dd03a8b32a431efa073 100644 --- a/scp/css/scp.css +++ b/scp/css/scp.css @@ -1672,10 +1672,33 @@ time.faq { padding-left: 19px; } -.advanced-search-field { +.adv-search-field { margin-top: 5px !important; } +#advanced-search fieldset { + margin-top: 3px; + position: relative; +} +#advanced-search .adv-search-method:before, +#advanced-search .adv-search-val:before { + content: ""; + border-left: 2px dotted #ccc; + border-bottom: 2px dotted #ccc; + border-color: rgba(0,0,0,0.15); + width: 10px; + height: 10px; + display: inline-block; + position: absolute; + left: -16px; +} +#advanced-search .adv-search-method { + margin-left: 24px; +} +#advanced-search .adv-search-val { + margin-left: 45px; +} + .dialog input[type="submit"], .dialog input[type="reset"], .dialog input[type="button"],