diff --git a/include/ajax.content.php b/include/ajax.content.php index dc1bc9ab1f9678913ee1581e3ea672d879d53671..0706757641e7bd82c5603218bafb76110aa92971 100644 --- a/include/ajax.content.php +++ b/include/ajax.content.php @@ -13,9 +13,10 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ - if(!defined('INCLUDE_DIR')) die('!'); +require_once INCLUDE_DIR.'class.ajax.php'; + class ContentAjaxAPI extends AjaxController { function log($id) { @@ -204,5 +205,22 @@ class ContentAjaxAPI extends AjaxController { $errors = Format::htmlchars($errors); include STAFFINC_DIR . 'templates/content-manage.tmpl.php'; } + + function context() { + global $thisstaff; + + if (!$thisstaff) + Http::response(403, 'Login Required'); + if (!$_GET['root']) + Http::response(400, '`root` is required parameter'); + + $items = VariableReplacer::getContextForRoot($_GET['root']); + + if (!$items) + Http::response(422, 'No such context'); + + header('Content-Type: application/json'); + return $this->encode($items); + } } ?> diff --git a/include/class.client.php b/include/class.client.php index 25efc2ca60132537015107d13d273b913bc13ee7..25b03281b9abd31d359d265592dcdc7058061a24 100644 --- a/include/class.client.php +++ b/include/class.client.php @@ -16,7 +16,7 @@ require_once INCLUDE_DIR.'class.user.php'; abstract class TicketUser -implements EmailContact, ITicketUser { +implements EmailContact, ITicketUser, TemplateVariable { static private $token_regex = '/^(?P<type>\w{1})(?P<algo>\d+)x(?P<hash>.*)$/i'; @@ -55,6 +55,19 @@ implements EmailContact, ITicketUser { } + // Required for Internationalization::getCurrentLanguage() in templates + function getLanguage() { + return $this->user->getLanguage(); + } + + static function getVarScope() { + return array( + 'email' => __('Email address'), + 'name' => array('class' => 'PersonsName', 'desc' => __('Full name')), + 'ticket_link' => __('Auth. token used for auto-login'), + ); + } + function getId() { return ($this->user) ? $this->user->getId() : null; } function getEmail() { return ($this->user) ? $this->user->getEmail() : null; } diff --git a/include/class.company.php b/include/class.company.php index c9aa22f04af3889ea22b564f06ffdf2dff936a71..6773bd80e83ef59e724744fd9b33663c0f269b57 100644 --- a/include/class.company.php +++ b/include/class.company.php @@ -17,7 +17,8 @@ require_once(INCLUDE_DIR.'class.forms.php'); require_once(INCLUDE_DIR.'class.dynamic_forms.php'); -class Company { +class Company +implements TemplateVariable { var $form; var $entry; @@ -59,6 +60,12 @@ class Company { return $this->getName(); } + static function getVarScope() { + return VariableReplacer::compileFormScope( + DynamicForm::lookup(array('type'=>'C')) + ); + } + function __toString() { try { if ($name = $this->getForm()->getAnswer('name')) diff --git a/include/class.dept.php b/include/class.dept.php index 39f914f9f401233909742e991d528c7b6551c1c3..43a98692d1af69cf0c67e2dd3d90dac30d0741ae 100644 --- a/include/class.dept.php +++ b/include/class.dept.php @@ -14,7 +14,8 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ -class Dept extends VerySimpleModel { +class Dept extends VerySimpleModel +implements TemplateVariable { static $meta = array( 'table' => DEPT_TABLE, @@ -71,6 +72,26 @@ class Dept extends VerySimpleModel { return $this->getName(); } + static function getVarScope() { + return array( + 'name' => 'Department name', + 'manager' => array( + 'class' => 'Staff', 'desc' => 'Department manager', + 'exclude' => 'dept', + ), + 'members' => array( + 'class' => 'UserList', 'desc' => 'Department members', + ), + 'parent' => array( + 'class' => 'Dept', 'desc' => 'Parent department', + ), + 'sla' => array( + 'class' => 'SLA', 'desc' => 'Service Level Agreement', + ), + 'signature' => 'Department signature', + ); + } + function getId() { return $this->id; } @@ -170,7 +191,7 @@ class Dept extends VerySimpleModel { $this->_members = $members->all(); } - return $this->_members; + return new UserList($this->_members); } function getAvailableMembers() { diff --git a/include/class.dynamic_forms.php b/include/class.dynamic_forms.php index d97e4b348683f47c6a1f6c9ac71668d303b9261c..e569f83792ae45880ee428c63b961e40d63cedf8 100644 --- a/include/class.dynamic_forms.php +++ b/include/class.dynamic_forms.php @@ -1268,13 +1268,14 @@ class DynamicFormEntryAnswer extends VerySimpleModel { } function asVar() { - return (is_object($this->getValue())) - ? $this->getValue() : $this->toString(); + return $this->getField()->asVar( + $this->get('value'), $this->get('value_id') + ); } function getVar($tag) { - if (is_object($this->getValue()) && method_exists($this->getValue(), 'getVar')) - return $this->getValue()->getVar($tag); + if (is_object($var = $this->asVar()) && method_exists($var, 'getVar')) + return $var->getVar($tag); } function __toString() { @@ -1383,6 +1384,15 @@ class SelectionField extends FormField { return $value; } + function asVar($value, $id=false) { + $values = $this->to_php($value, $id); + if (is_array($values)) { + return new PlaceholderList($this->getList()->getAllItems() + ->filter(array('id__in' => array_keys($values))) + ); + } + } + function hasSubFields() { return $this->getList()->getForm(); } diff --git a/include/class.filter_action.php b/include/class.filter_action.php index 531b997e3008354a0a1bc84f25d62121f1bdf50c..7de00efe9e82d79e974d3bd2411c60161920baf6 100644 --- a/include/class.filter_action.php +++ b/include/class.filter_action.php @@ -453,15 +453,42 @@ class FA_SendEmail extends TriggerAction { if (!$config['from'] || !($mailer = Email::lookup($config['from']))) $mailer = new Mailer(); - // Honor %{user} variable - $to = $config['recipients']; + // Allow %{user} in the To: line $replacer = new VariableReplacer(); $replacer->assign(array( - 'user' => sprintf('%s <%s>', $ticket['name'], $ticket['email']) + 'user' => sprintf('"%s" <%s>', $ticket['name'], $ticket['email']) )); - $to = $replacer->replaceVars($to); + $to = $replacer->replaceVars($config['recipients']); + + require_once PEAR_DIR . 'Mail/RFC822.php'; + require_once PEAR_DIR . 'PEAR.php'; + + if (!($mails = Mail_RFC822::parseAddressList($to)) || PEAR::isError($mails)) + return false; + + // Allow %{recipient} in the body + foreach ($mails as $R) { + $recipient = sprintf('%s <%s@%s>', $R->personal, $R->mailbox, $R->host); + $replacer->assign(array( + 'recipient' => new EmailAddress($recipient), + )); + $I = $replacer->replaceVars($info); + $mailer->send($recipient, $I['subject'], $I['message']); + } + + } - $mailer->send($to, $info['subject'], $info['message']); + static function getVarScope() { + $context = array( + 'ticket' => array( + 'class' => 'FA_SendEmail_TicketInfo', 'desc' => __('Ticket'), + ), + 'user' => __('Ticket Submitter'), + 'recipient' => array( + 'class' => 'EmailAddress', 'desc' => __('Recipient'), + ), + ) + osTicket::getVarScope(); + return VariableReplacer::compileScope($context); } function getConfigurationOptions() { @@ -504,6 +531,7 @@ class FA_SendEmail extends TriggerAction { 'configuration' => array( 'placeholder' => __('Message'), 'html' => true, + 'context' => 'fa:send_email', ), )), 'from' => new ChoiceField(array( @@ -515,3 +543,12 @@ class FA_SendEmail extends TriggerAction { } } FilterAction::register('FA_SendEmail', /* @trans */ 'Communication'); + +class FA_SendEmail_TicketInfo { + static function getVarScope() { + return array( + 'message' => __('Message from the EndUser'), + 'source' => __('Source'), + ); + } +} diff --git a/include/class.format.php b/include/class.format.php index 83eb012168a119a405549be8e9872240f4808f17..52b2bcff56f6c1612b9b0dd100c30c54ec4af3dd 100644 --- a/include/class.format.php +++ b/include/class.format.php @@ -438,7 +438,7 @@ class Format { } function __formatDate($timestamp, $format, $fromDb, $dayType, $timeType, - $strftimeFallback, $timezone) { + $strftimeFallback, $timezone, $user=false) { global $cfg; if ($timestamp && $fromDb) { @@ -446,7 +446,7 @@ class Format { } if (class_exists('IntlDateFormatter')) { $formatter = new IntlDateFormatter( - Internationalization::getCurrentLocale(), + Internationalization::getCurrentLocale($user), $dayType, $timeType, $timezone, @@ -492,40 +492,40 @@ class Format { return strtotime($date); } - function time($timestamp, $fromDb=true, $format=false, $timezone=false) { + function time($timestamp, $fromDb=true, $format=false, $timezone=false, $user=false) { global $cfg; return self::__formatDate($timestamp, $format ?: $cfg->getTimeFormat(), $fromDb, IDF_NONE, IDF_SHORT, - '%x', $timezone ?: $cfg->getTimezone()); + '%x', $timezone ?: $cfg->getTimezone(), $user); } - function date($timestamp, $fromDb=true, $format=false, $timezone=false) { + function date($timestamp, $fromDb=true, $format=false, $timezone=false, $user=false) { global $cfg; return self::__formatDate($timestamp, $format ?: $cfg->getDateFormat(), $fromDb, IDF_SHORT, IDF_NONE, - '%X', $timezone ?: $cfg->getTimezone()); + '%X', $timezone ?: $cfg->getTimezone(), $user); } - function datetime($timestamp, $fromDb=true, $timezone=false) { + function datetime($timestamp, $fromDb=true, $timezone=false, $user=false) { global $cfg; return self::__formatDate($timestamp, $cfg->getDateTimeFormat(), $fromDb, IDF_SHORT, IDF_SHORT, - '%X %x', $timezone ?: $cfg->getTimezone()); + '%X %x', $timezone ?: $cfg->getTimezone(), $user); } - function daydatetime($timestamp, $fromDb=true, $timezone=false) { + function daydatetime($timestamp, $fromDb=true, $timezone=false, $user=false) { global $cfg; return self::__formatDate($timestamp, $cfg->getDayDateTimeFormat(), $fromDb, IDF_FULL, IDF_SHORT, - '%X %x', $timezone ?: $cfg->getTimezone()); + '%X %x', $timezone ?: $cfg->getTimezone(), $user); } function getStrftimeFormat($format) { @@ -710,6 +710,84 @@ class Format { } return $text; } + + function relativeTime($to, $from=false) { + $timestamp = $to ?: Misc::gmtime(); + if (gettype($timestamp) === 'string') + $timestamp = strtotime($timestamp); + $from = $from ?: Misc::gmtime(); + if (gettype($timestamp) === 'string') + $from = strtotime($from); + $timeDiff = $from - $timestamp; + $absTimeDiff = abs($timeDiff); + + // within 2 seconds + if ($absTimeDiff <= 2) { + return $timeDiff >= 0 ? __('just now') : __('now'); + } + + // within a minute + if ($absTimeDiff < 60) { + return sprintf($timeDiff >= 0 ? __('%d seconds ago') : __('in %d seconds'), $absTimeDiff); + } + + // within 2 minutes + if ($absTimeDiff < 120) { + return sprintf($timeDiff >= 0 ? __('about a minute ago') : __('in about a minute')); + } + + // within an hour + if ($absTimeDiff < 3600) { + return sprintf($timeDiff >= 0 ? __('%d minutes ago') : __('in %d minutes'), $absTimeDiff / 60); + } + + // within 2 hours + if ($absTimeDiff < 7200) { + return ($timeDiff >= 0 ? __('about an hour ago') : __('in about an hour')); + } + + // within 24 hours + if ($absTimeDiff < 86400) { + return sprintf($timeDiff >= 0 ? __('%d hours ago') : __('in %d hours'), $absTimeDiff / 3600); + } + + // within 2 days + $days2 = 2 * 86400; + if ($absTimeDiff < $days2) { + // XXX: yesterday / tomorrow? + return $absTimeDiff >= 0 ? __('1 day ago') : __('in 1 day'); + } + + // within 29 days + $days29 = 29 * 86400; + if ($absTimeDiff < $days29) { + return sprintf($timeDiff >= 0 ? __('%d days ago') : __('in %d days'), $absTimeDiff / 86400); + } + + // within 60 days + $days60 = 60 * 86400; + if ($absTimeDiff < $days60) { + return ($timeDiff >= 0 ? __('about a month ago') : __('in about a month')); + } + + $currTimeYears = date('Y', $from); + $timestampYears = date('Y', $timestamp); + $currTimeMonths = $currTimeYears * 12 + date('n', $from); + $timestampMonths = $timestampYears * 12 + date('n', $timestamp); + + // within a year + $monthDiff = $currTimeMonths - $timestampMonths; + if ($monthDiff < 12 && $monthDiff > -12) { + return sprintf($monthDiff >= 0 ? __('%d months ago') : __('in %d months'), abs($monthDiff)); + } + + $yearDiff = $currTimeYears - $timestampYears; + if ($yearDiff < 2 && $yearDiff > -2) { + return $yearDiff >= 0 ? __('a year ago') : __('in a year'); + } + + return sprintf($yearDiff >= 0 ? __('%d years ago') : __('in %d years'), abs($yearDiff)); + } } if (!class_exists('IntlDateFormatter')) { @@ -722,4 +800,103 @@ else { define('IDF_SHORT', IntlDateFormatter::SHORT); define('IDF_FULL', IntlDateFormatter::FULL); } + +class FormattedLocalDate +implements TemplateVariable { + var $date; + var $timezone; + var $fromdb; + + function __construct($date, $timezone=false, $user=false, $fromdb=true) { + $this->date = $date; + $this->timezone = $timezone; + $this->user = $user; + $this->fromdb = $fromdb; + } + + function asVar() { + return $this->getVar('long'); + } + + function __toString() { + return $this->asVar(); + } + + function getVar($what) { + global $cfg; + + if (method_exists($this, 'get' . ucfirst($what))) + return call_user_func(array($this, 'get'.ucfirst($what))); + + // TODO: Rebase date format so that locale is discovered HERE. + + switch ($what) { + case 'short': + return Format::date($this->date, $this->fromdb, false, $this->timezone, $this->user); + case 'long': + return Format::datetime($this->date, $this->fromdb, $this->timezone, $this->user); + case 'time': + return Format::time($this->date, $this->fromdb, false, $this->timezone, $this->user); + case 'full': + return Format::daydatetime($this->date, $this->fromdb, $this->timezone, $this->user); + } + } + + static function getVarScope() { + return array( + 'full' => 'Expanded date, e.g. day, month dd, yyyy', + 'long' => 'Date and time, e.g. d/m/yyyy hh:mm', + 'short' => 'Date only, e.g. d/m/yyyy', + 'time' => 'Time only, e.g. hh:mm', + ); + } +} + +class FormattedDate +extends FormattedLocalDate { + function asVar() { + return $this->getVar('system')->asVar(); + } + + function __toString() { + global $cfg; + return (string) new FormattedLocalDate($this->date, $cfg->getTimezone(), false, $this->fromdb); + } + + function getVar($what) { + global $cfg; + + if ($rv = parent::getVar($what)) + return $rv; + + switch ($what) { + case 'system': + return new FormattedLocalDate($this->date, $cfg->getDefaultTimezone()); + } + } + + function getHumanize() { + return Format::relativeTime(Misc::db2gmtime($this->date)); + } + + function getUser($context) { + global $cfg; + + // Fetch $recipient from the context and find that user's time zone + if ($recipient = $context->getObj('recipient')) { + $tz = $recipient->getTimezone() ?: $cfg->getDefaultTimezone(); + return new FormattedLocalDate($this->date, $tz, $recipient); + } + } + + static function getVarScope() { + return parent::getVarScope() + array( + 'humanize' => 'Humanized time, e.g. about an hour ago', + 'user' => array( + 'class' => 'FormattedLocalDate', 'desc' => "Localize to recipient's time zone and locale"), + 'system' => array( + 'class' => 'FormattedLocalDate', 'desc' => 'Localize to system default time zone'), + ); + } +} ?> diff --git a/include/class.forms.php b/include/class.forms.php index 0733afd4ea00500d5dcf55ba64609c1ebd81bc10..59b2d7251b23155e2c9dad61465a88c006415e44 100644 --- a/include/class.forms.php +++ b/include/class.forms.php @@ -518,6 +518,24 @@ class FormField { return $this->toString($value); } + /** + * Fetch a value suitable for embedding the value of this field in an + * email template. Reference implementation uses ::to_php(); + */ + function asVar($value, $id=false) { + return $this->to_php($value, $id); + } + + /** + * Fetch the var type used with the email templating system's typeahead + * feature. This helps with variable expansion if supported by this + * field's ::asVar() method. This method should return a valid classname + * which implements the `TemplateVariable` interface. + */ + function asVarType() { + return false; + } + /** * Convert the field data to something matchable by filtering. The * primary use of this is for ticket filtering. @@ -1297,6 +1315,14 @@ class DatetimeField extends FormField { return (int) $value; } + function asVar($value, $id=false) { + if (!$value) return null; + return new FormattedDate((int) $value, 'UTC', false, false); + } + function asVarType() { + return 'FormattedDate'; + } + function toString($value) { global $cfg; $config = $this->getConfiguration(); @@ -2162,6 +2188,45 @@ class FileUploadField extends FormField { $this->attachments->deleteAll(); } } + + function asVar($value, $id=false) { + return new FileFieldAttachments($this->getFiles()); + } + function asVarType() { + return 'FileFieldAttachments'; + } +} + +class FileFieldAttachments { + var $files; + + function __construct($files) { + $this->files = $files; + } + + function __toString() { + $files = array(); + foreach ($this->files as $f) { + $files[] = $f->file->name; + } + return implode(', ', $files); + } + + function getVar($tag) { + switch ($tag) { + case 'names': + return $this->__toString(); + case 'files': + throw new OOBContent(OOBContent::FILES, $this->files->all()); + } + } + + static function getVarScope() { + return array( + 'names' => __('List of file names'), + 'files' => __('Attached files'), + ); + } } class InlineFormData extends ArrayObject { @@ -2394,6 +2459,7 @@ class TextareaWidget extends Widget { function render($options=array()) { $config = $this->field->getConfiguration(); $class = $cols = $rows = $maxlength = ""; + $attrs = array(); if (isset($config['rows'])) $rows = "rows=\"{$config['rows']}\""; if (isset($config['cols'])) @@ -2406,9 +2472,12 @@ class TextareaWidget extends Widget { $class = sprintf('class="%s"', implode(' ', $class)); $this->value = Format::viewableImages($this->value); } + if (isset($config['context'])) + $attrs['data-root-context'] = '"'.$config['context'].'"'; ?> <span style="display:inline-block;width:100%"> <textarea <?php echo $rows." ".$cols." ".$maxlength." ".$class + .' '.Format::array_implode('=', ' ', $attrs) .' placeholder="'.$config['placeholder'].'"'; ?> id="<?php echo $this->id; ?>" name="<?php echo $this->name; ?>"><?php diff --git a/include/class.i18n.php b/include/class.i18n.php index e0fcd9e11b9d864f66326bd9735abb244dfcd77a..c6e4bfc61b50973d19a3a2ad38d140178c2ef49f 100644 --- a/include/class.i18n.php +++ b/include/class.i18n.php @@ -379,14 +379,17 @@ class Internationalization { return self::getDefaultLanguage(); } - static function getCurrentLocale() { + static function getCurrentLocale($user=false) { global $thisstaff, $cfg; + if ($user) { + return self::getCurrentLanguage($user); + } // FIXME: Move this majic elsewhere - see upgrade bug note in // class.staff.php if ($thisstaff) { return $thisstaff->getLocale() - ?: self::getCurrentLanguage(); + ?: self::getCurrentLanguage($thisstaff); } if (!($locale = $cfg->getDefaultLocale())) diff --git a/include/class.list.php b/include/class.list.php index 12d2a85565a4062c4f8c24aca91ada5378bfd404..e873f4ee9a37d683422cd5e32cb3de19baaba3f5 100644 --- a/include/class.list.php +++ b/include/class.list.php @@ -14,8 +14,8 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ - require_once(INCLUDE_DIR .'class.dynamic_forms.php'); +require_once(INCLUDE_DIR .'class.variable.php'); /** * Interface for Custom Lists @@ -746,7 +746,7 @@ class DynamicListItem extends VerySimpleModel implements CustomListItem { $name = mb_strtolower($name); foreach ($this->getConfigurationForm()->getFields() as $field) { if (mb_strtolower($field->get('name')) == $name) - return $config[$field->get('id')]; + return $field->asVar($config[$field->get('id')]); } } @@ -948,7 +948,9 @@ class TicketStatusList extends CustomListHandler { } } -class TicketStatus extends VerySimpleModel implements CustomListItem { +class TicketStatus +extends VerySimpleModel +implements CustomListItem, TemplateVariable { static $meta = array( 'table' => TICKET_STATUS_TABLE, @@ -1204,6 +1206,15 @@ class TicketStatus extends VerySimpleModel implements CustomListItem { return $T != $tag ? $T : $default; } + // TemplateVariable interface + static function getVarScope() { + $base = array( + 'name' => __('Status label'), + 'state' => __('State name (e.g. open or closed)'), + ); + return $base; + } + function getConfiguration() { if (!$this->_settings) { diff --git a/include/class.mailer.php b/include/class.mailer.php index 0abb652469fa8d6491dcd5efa1f37ac5bc139664..412428eccdf11d370951141fc91e3cd358ab5386 100644 --- a/include/class.mailer.php +++ b/include/class.mailer.php @@ -386,6 +386,17 @@ class Mailer { } $mime = new Mail_mime($eol); + // Add in extra attachments, if any from template variables + if ($message instanceof TextWithExtras + && ($files = $message->getFiles()) + ) { + foreach ($files as $F) { + $file = $F->getFile(); + $mime->addAttachment($file->getData(), + $file->getType(), $file->getName(), false); + } + } + // If the message is not explicitly declared to be a text message, // then assume that it needs html processing to create a valid text // body diff --git a/include/class.organization.php b/include/class.organization.php index ffd25216f52026e82a41d0f4a9b105bd82f17a5c..cc011495fa3e13114ffd8af3e2863946ca86d3f8 100644 --- a/include/class.organization.php +++ b/include/class.organization.php @@ -147,7 +147,8 @@ class OrganizationCdata extends VerySimpleModel { } -class Organization extends OrganizationModel { +class Organization extends OrganizationModel +implements TemplateVariable { var $_entries; var $_forms; @@ -299,6 +300,28 @@ class Organization extends OrganizationModel { foreach ($this->getDynamicData() as $e) if ($a = $e->getAnswer($tag)) return $a; + + switch ($tag) { + case 'members': + return new UserList($this->users); + case 'manager': + return $this->getAccountManager(); + case 'contacts': + return new UserList($this->users->filter(array( + 'flags__hasbit' => User::PRIMARY_ORG_CONTACT + ))); + } + } + + static function getVarScope() { + $base = array( + 'contacts' => array('class' => 'UserList', 'desc' => __('Primary Contacts')), + 'manager' => __('Account Manager'), + 'members' => array('class' => 'UserList', 'desc' => __('Organization Members')), + 'name' => __('Name'), + ); + $extra = VariableReplacer::compileFormScope(OrganizationForm::getInstance()); + return $base + $extra; } function update($vars, &$errors) { diff --git a/include/class.osticket.php b/include/class.osticket.php index 15500c232ddbc3920fb0c5b73848d4c9e6ebebbc..75a247c1072a7cf3143c8929a4ef46e13b228d1b 100644 --- a/include/class.osticket.php +++ b/include/class.osticket.php @@ -145,6 +145,13 @@ class osTicket { return $replacer->replaceVars($input); } + static function getVarScope() { + return array( + 'url' => __("osTicket's base url (FQDN)"), + 'company' => array('class' => 'Company', 'desc' => __('Company Information')), + ); + } + function addExtraHeader($header, $pjax_script=false) { $this->headers[md5($header)] = $header; $this->pjax_extra[md5($header)] = $pjax_script; diff --git a/include/class.page.php b/include/class.page.php index 1aaa8ce3d8c8e0f934bd95e15f402e58fd74763b..a49a7b263d12632ee662aba4c1288c2bcf4616b9 100644 --- a/include/class.page.php +++ b/include/class.page.php @@ -341,5 +341,34 @@ class Page extends VerySimpleModel { } return true; } + + static function getContext($type) { + $context = array( + 'thank-you' => array('ticket'), + 'registration-staff' => array( + // 'token' => __('Special authentication token'), + 'staff' => array('class' => 'Staff', 'desc' => __('Message recipient')), + 'recipient' => array('class' => 'Staff', 'desc' => __('Message recipient')), + 'link', + ), + 'pwreset-staff' => array( + 'staff' => array('class' => 'Staff', 'desc' => __('Message recipient')), + 'recipient' => array('class' => 'Staff', 'desc' => __('Message recipient')), + 'link', + ), + 'registration-client' => array( + // 'token' => __('Special authentication token'), + 'recipient' => array('class' => 'User', 'desc' => __('Message recipient')), + 'link', 'user', + ), + 'pwreset-client' => array( + 'recipient' => array('class' => 'User', 'desc' => __('Message recipient')), + 'link', 'user', + ), + 'access-link' => array('ticket', 'recipient'), + ); + + return $context[$type]; + } } ?> diff --git a/include/class.priority.php b/include/class.priority.php index ac6976d29bb53517b41ee82d5dc4bf536e124bf0..8721ad81182028b942d7bc565ca71458f7bd3620 100644 --- a/include/class.priority.php +++ b/include/class.priority.php @@ -14,7 +14,8 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ -class Priority extends VerySimpleModel { +class Priority extends VerySimpleModel +implements TemplateVariable { static $meta = array( 'table' => PRIORITY_TABLE, @@ -46,6 +47,14 @@ class Priority extends VerySimpleModel { return $this->ispublic; } + // TemplateVariable interface + function asVar() { return $this->getDesc(); } + static function getVarScope() { + return array( + 'desc' => __('Priority Level'), + ); + } + function __toString() { return $this->getDesc(); } diff --git a/include/class.sla.php b/include/class.sla.php index 8edb7a4360099b32506af1e563850e44887294a8..c1b4ead65df6f5d1ec796f7655e1d0a4322519f1 100644 --- a/include/class.sla.php +++ b/include/class.sla.php @@ -13,7 +13,8 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ -class SLA extends VerySimpleModel { +class SLA extends VerySimpleModel +implements TemplateVariable { static $meta = array( 'table' => SLA_TABLE, @@ -95,6 +96,18 @@ class SLA extends VerySimpleModel { return $T != $tag ? $T : $default; } + // TemplateVariable interface + function asVar() { + return $this->getName(); + } + + static function getVarScope() { + return array( + 'name' => __('SLA Plan'), + 'graceperiod' => __("Grace Period (hrs)"), + ); + } + function update($vars, &$errors) { if (!$vars['grace_period']) diff --git a/include/class.staff.php b/include/class.staff.php index 4a13e1b382104d893ad1aae63376667ca53ce48c..9e093d7fcc93dcf349b1761f1bacd1a77ef2d839 100644 --- a/include/class.staff.php +++ b/include/class.staff.php @@ -24,7 +24,7 @@ include_once(INCLUDE_DIR.'class.user.php'); include_once(INCLUDE_DIR.'class.auth.php'); class Staff extends VerySimpleModel -implements AuthenticatedUser, EmailContact { +implements AuthenticatedUser, EmailContact, TemplateVariable { static $meta = array( 'table' => STAFF_TABLE, @@ -78,6 +78,30 @@ implements AuthenticatedUser, EmailContact { return $this->__toString(); } + static function getVarScope() { + return array( + 'dept' => array('class' => 'Dept', 'desc' => __('Department')), + 'email' => __('Email Address'), + 'name' => array( + 'class' => 'PersonsName', 'desc' => __('Agent name'), + ), + 'mobile' => __('Mobile Number'), + 'phone' => __('Phone Number'), + 'signature' => __('Signature'), + 'timezone' => "Agent's configured timezone", + 'username' => 'Access username', + ); + } + + function getVar($tag) { + switch ($tag) { + case 'mobile': + return Format::phone($this->ht['mobile']); + case 'phone': + return Format::phone($this->ht['phone']); + } + } + function getHashtable() { $base = $this->ht; $base['group'] = $base['group_id']; diff --git a/include/class.team.php b/include/class.team.php index 5a81170782a7225316d335b225ff3e0c2caeb622..04427634a0bcce591e1e460737f34cd445e5e56b 100644 --- a/include/class.team.php +++ b/include/class.team.php @@ -14,7 +14,8 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ -class Team extends VerySimpleModel { +class Team extends VerySimpleModel +implements TemplateVariable { static $meta = array( 'table' => TEAM_TABLE, @@ -42,6 +43,18 @@ class Team extends VerySimpleModel { return (string) $this->getName(); } + static function getVarScope() { + return array( + 'name' => __('Team Name'), + 'lead' => array( + 'class' => 'Staff', 'desc' => __('Team Lead'), + ), + 'members' => array( + 'class' => 'UserList', 'desc' => __('Team Members'), + ), + ); + } + function getId() { return $this->team_id; } @@ -62,7 +75,7 @@ class Team extends VerySimpleModel { $this->_members[] = $m->staff; } - return $this->_members; + return new UserList($this->_members); } function hasMember($staff) { diff --git a/include/class.template.php b/include/class.template.php index 00a81c066c486a9fe9f95c1fea17b3bae57c683e..a48e5255d47dac70bea9234388b6ef81f35b6bf3 100644 --- a/include/class.template.php +++ b/include/class.template.php @@ -30,56 +30,108 @@ class EmailTemplateGroup { 'ticket.autoresp'=>array( 'group'=>'ticket.user', 'name'=>/* @trans */ 'New Ticket Auto-response', - 'desc'=>/* @trans */ 'Autoresponse sent to user, if enabled, on new ticket.'), + 'desc'=>/* @trans */ 'Autoresponse sent to user, if enabled, on new ticket.', + 'context' => array( + 'ticket', 'signature', 'message', 'recipient' + ), + ), 'ticket.autoreply'=>array( 'group'=>'ticket.user', 'name'=>/* @trans */ 'New Ticket Auto-reply', - 'desc'=>/* @trans */ 'Canned Auto-reply sent to user on new ticket, based on filter matches. Overwrites "normal" auto-response.'), + 'desc'=>/* @trans */ 'Canned Auto-reply sent to user on new ticket, based on filter matches. Overwrites "normal" auto-response.', + 'context' => array( + 'ticket', 'signature', 'response', 'recipient', + ), + ), 'message.autoresp'=>array( 'group'=>'ticket.user', 'name'=>/* @trans */ 'New Message Auto-response', - 'desc'=>/* @trans */ 'Confirmation sent to user when a new message is appended to an existing ticket.'), + 'desc'=>/* @trans */ 'Confirmation sent to user when a new message is appended to an existing ticket.', + 'context' => array( + 'ticket', 'signature', 'recipient', + ), + ), 'ticket.notice'=>array( 'group'=>'ticket.user', 'name'=>/* @trans */ 'New Ticket Notice', - 'desc'=>/* @trans */ 'Notice sent to user, if enabled, on new ticket created by an agent on their behalf (e.g phone calls).'), + 'desc'=>/* @trans */ 'Notice sent to user, if enabled, on new ticket created by an agent on their behalf (e.g phone calls).', + 'context' => array( + 'ticket', 'signature', 'recipient', 'staff', 'message', + ), + ), 'ticket.overlimit'=>array( 'group'=>'ticket.user', 'name'=>/* @trans */ 'Over Limit Notice', - 'desc'=>/* @trans */ 'A one-time notice sent, if enabled, when user has reached the maximum allowed open tickets.'), + 'desc'=>/* @trans */ 'A one-time notice sent, if enabled, when user has reached the maximum allowed open tickets.', + 'context' => array( + 'ticket', 'signature', + ), + ), 'ticket.reply'=>array( 'group'=>'ticket.user', 'name'=>/* @trans */ 'Response/Reply Template', - 'desc'=>/* @trans */ 'Template used on ticket response/reply'), + 'desc'=>/* @trans */ 'Template used on ticket response/reply', + 'context' => array( + 'ticket', 'signature', 'response', 'staff', 'poster', 'recipient', + ), + ), 'ticket.activity.notice'=>array( 'group'=>'ticket.user', 'name'=>/* @trans */ 'New Activity Notice', - 'desc'=>/* @trans */ 'Template used to notify collaborators on ticket activity (e.g CC on reply)'), + 'desc'=>/* @trans */ 'Template used to notify collaborators on ticket activity (e.g CC on reply)', + 'context' => array( + 'ticket', 'signature', 'message', 'poster', 'recipient', + ), + ), 'ticket.alert'=>array( 'group'=>'ticket.staff', 'name'=>/* @trans */ 'New Ticket Alert', - 'desc'=>/* @trans */ 'Alert sent to agents, if enabled, on new ticket.'), + 'desc'=>/* @trans */ 'Alert sent to agents, if enabled, on new ticket.', + 'context' => array( + 'ticket', 'recipient', 'message', + ), + ), 'message.alert'=>array( 'group'=>'ticket.staff', 'name'=>/* @trans */ 'New Message Alert', - 'desc'=>/* @trans */ 'Alert sent to agents, if enabled, when user replies to an existing ticket.'), + 'desc'=>/* @trans */ 'Alert sent to agents, if enabled, when user replies to an existing ticket.', + 'context' => array( + 'ticket', 'recipient', 'message', 'poster', + ), + ), 'note.alert'=>array( 'group'=>'ticket.staff', 'name'=>/* @trans */ 'Internal Activity Alert', - 'desc'=>/* @trans */ 'Alert sent out to Agents when internal activity such as an internal note or an agent reply is appended to a ticket.'), + 'desc'=>/* @trans */ 'Alert sent out to Agents when internal activity such as an internal note or an agent reply is appended to a ticket.', + 'context' => array( + 'ticket', 'recipient', 'note', 'comments', 'activity', + ), + ), 'assigned.alert'=>array( 'group'=>'ticket.staff', 'name'=>/* @trans */ 'Ticket Assignment Alert', - 'desc'=>/* @trans */ 'Alert sent to agents on ticket assignment.'), + 'desc'=>/* @trans */ 'Alert sent to agents on ticket assignment.', + 'context' => array( + 'ticket', 'recipient', 'comments', 'assignee', 'assigner', + ), + ), 'transfer.alert'=>array( 'group'=>'ticket.staff', 'name'=>/* @trans */ 'Ticket Transfer Alert', - 'desc'=>/* @trans */ 'Alert sent to agents on ticket transfer.'), + 'desc'=>/* @trans */ 'Alert sent to agents on ticket transfer.', + 'context' => array( + 'ticket', 'recipient', 'comments', 'staff', + ), + ), 'ticket.overdue'=>array( 'group'=>'ticket.staff', 'name'=>/* @trans */ 'Overdue Ticket Alert', - 'desc'=>/* @trans */ 'Alert sent to agents on stale or overdue tickets.'), - ); + 'desc'=>/* @trans */ 'Alert sent to agents on stale or overdue tickets.', + 'context' => array( + 'ticket', 'recipient', 'comments', + ), + ), + ); function EmailTemplateGroup($id){ $this->id=0; @@ -157,7 +209,7 @@ class EmailTemplateGroup { return (db_query($sql) && db_affected_rows()); } - function getTemplateDescription($name) { + static function getTemplateDescription($name) { return static::$all_names[$name]; } @@ -456,6 +508,22 @@ class EmailTemplate { return $this->getGroup()->getTemplateDescription($this->ht['code_name']); } + function getInvalidVariableUsage() { + $context = VariableReplacer::getContextForRoot($this->ht['code_name']); + $invalid = array(); + foreach (array($this->getSubject(), $this->getBody()) as $B) { + $variables = array(); + if (!preg_match_all('`%\{([^}]*)\}`', $B, $variables, PREG_SET_ORDER)) + continue; + foreach ($variables as $V) { + if (!isset($context[$V[1]])) { + $invalid[] = $V[0]; + } + } + } + return $invalid; + } + function update($vars, &$errors) { if(!$this->save($this->getId(),$vars,$errors)) diff --git a/include/class.thread.php b/include/class.thread.php index 2b1d8bec7963775e21de4a6e4a67c2c0ede44286..4b3771e10c864cf0c611ea4d6b35d9a23b333fe9 100644 --- a/include/class.thread.php +++ b/include/class.thread.php @@ -396,7 +396,8 @@ class ThreadEntryEmailInfo extends VerySimpleModel { ); } -class ThreadEntry extends VerySimpleModel { +class ThreadEntry extends VerySimpleModel +implements TemplateVariable { static $meta = array( 'table' => THREAD_ENTRY_TABLE, 'pk' => array('id'), @@ -821,17 +822,6 @@ class ThreadEntry extends VerySimpleModel { return $str; } - /* Returns file names with id as key */ - function getFiles() { - - $files = array(); - foreach($this->attachments as $attachment) - $files[$attachment->file_id] = $attachment->file->name; - - return $files; - } - - /* save email info * TODO: Refactor it to include outgoing emails on responses. */ @@ -873,27 +863,46 @@ class ThreadEntry extends VerySimpleModel { return (string) $this->getBody(); } + // TemplateVariable interface function asVar() { return (string) $this->getBody()->display('email'); } function getVar($tag) { - global $cfg; - - if($tag && is_callable(array($this, 'get'.ucfirst($tag)))) + if ($tag && is_callable(array($this, 'get'.ucfirst($tag)))) return call_user_func(array($this, 'get'.ucfirst($tag))); switch(strtolower($tag)) { case 'create_date': - // XXX: Consider preferences of receiving user - return Format::datetime($this->getCreateDate(), true, 'UTC'); + return new FormattedDate($this->getCreateDate()); case 'update_date': - return Format::datetime($this->getUpdateDate(), true, 'UTC'); + return new FormattedDate($this->getUpdateDate()); + case 'files': + throw new OOBContent(OOBContent::FILES, $this->attachments->all()); } return false; } + static function getVarScope() { + return array( + 'files' => __('Attached files'), + 'body' => __('Message body'), + 'create_date' => array( + 'class' => 'FormattedDate', 'desc' => __('Date created'), + ), + 'ip_address' => __('IP address of remote user, for web submissions'), + 'poster' => __('Submitter of the thread item'), + 'staff' => array( + 'class' => 'Staff', 'desc' => __('Agent posting the note or response'), + ), + 'title' => __('Subject, if any'), + 'user' => array( + 'class' => 'User', 'desc' => __('User posting the message'), + ), + ); + } + /** * Parameters: * mailinfo (hash<String>) email header information. Must include keys @@ -1528,6 +1537,12 @@ class MessageThreadEntry extends ThreadEntry { return parent::add($vars); } + + static function getVarScope() { + $base = parent::getVarScope(); + unset($base['staff']); + return $base; + } } /* thread entry of type response */ @@ -1568,6 +1583,12 @@ class ResponseThreadEntry extends ThreadEntry { return parent::add($vars); } + + static function getVarScope() { + $base = parent::getVarScope(); + unset($base['user']); + return $base; + } } /* Thread entry of type note (Internal Note) */ @@ -1598,10 +1619,17 @@ class NoteThreadEntry extends ThreadEntry { return parent::add($vars); } + + static function getVarScope() { + $base = parent::getVarScope(); + unset($base['user']); + return $base; + } } // Object specific thread utils. -class ObjectThread extends Thread { +class ObjectThread extends Thread +implements TemplateVariable { private $_entries = array(); static $types = array( @@ -1729,6 +1757,13 @@ class ObjectThread extends Thread { } } + static function getVarScope() { + return array( + 'original' => array('class' => 'MessageThreadEntry', 'desc' => __('Original Message')), + 'lastmessage' => array('class' => 'MessageThreadEntry', 'desc' => __('Last Message')), + ); + } + static function lookup($criteria, $type=false) { if (!$type) return parent::lookup($criteria); diff --git a/include/class.ticket.php b/include/class.ticket.php index 2a14829dc83ed5d3658e7e15a7f750a04a6cc874..c275127847b51731ed838b4240d7d56d8bd594e1 100644 --- a/include/class.ticket.php +++ b/include/class.ticket.php @@ -212,7 +212,7 @@ TicketCData::$meta['table'] = TABLE_PREFIX . 'ticket__cdata'; class Ticket -implements RestrictedAccess, Threadable { +implements RestrictedAccess, Threadable, TemplateVariable { var $id; var $number; @@ -294,8 +294,8 @@ implements RestrictedAccess, Threadable { if (!$this->_answers) { foreach (DynamicFormEntry::forTicket($this->getId(), true) as $form) { foreach ($form->getAnswers() as $answer) { - $tag = mb_strtolower($answer->getField()->get('name')) - ?: 'field.' . $answer->getField()->get('id'); + $tag = mb_strtolower($answer->field->name) + ?: 'field.' . $answer->field->id; $this->_answers[$tag] = $answer; } } @@ -1797,7 +1797,7 @@ implements RestrictedAccess, Threadable { } - //ticket obj as variable = ticket number. + // TemplateVariable interface function asVar() { return $this->getNumber(); } @@ -1824,25 +1824,20 @@ implements RestrictedAccess, Threadable { return sprintf('%s/scp/tickets.php?id=%d', $cfg->getBaseUrl(), $this->getId()); break; case 'create_date': - return Format::datetime($this->getCreateDate(), true, 'UTC'); + return new FormattedDate($this->getCreateDate()); break; case 'due_date': - $duedate =''; - if($this->getEstDueDate()) - $duedate = Format::datetime($this->getEstDueDate(), true, 'UTC'); - - return $duedate; + if ($due = $this->getEstDueDate()) + return new FormattedDate($due); break; case 'close_date': - $closedate =''; - if($this->isClosed()) - $closedate = Format::datetime($this->getCloseDate(), true, 'UTC'); - - return $closedate; + if ($this->isClosed()) + return new FormattedDate($this->getCloseDate()); break; + case 'last_update': + return new FormattedDate($upd); case 'user': return $this->getOwner(); - break; default: if (isset($this->_answers[$tag])) // The answer object is retrieved here which will @@ -1854,6 +1849,63 @@ implements RestrictedAccess, Threadable { return false; } + static function getVarScope() { + $base = array( + 'assigned' => __('Assigned agent and/or team'), + 'close_date' => array( + 'class' => 'FormattedDate', 'desc' => __('Date Closed'), + ), + 'create_date' => array( + 'class' => 'FormattedDate', 'desc' => __('Date created'), + ), + 'dept' => array( + 'class' => 'Dept', 'desc' => __('Department'), + ), + 'due_date' => array( + 'class' => 'FormattedDate', 'desc' => __('Due Date'), + ), + 'email' => __('Default email address of ticket owner'), + 'name' => array( + 'class' => 'PersonsName', 'desc' => __('Name of ticket owner'), + ), + 'number' => __('Ticket number'), + 'phone' => __('Phone number of ticket owner'), + 'priority' => array( + 'class' => 'Priority', 'desc' => __('Priority'), + ), + 'recipients' => array( + 'class' => 'UserList', 'desc' => __('List of all recipient names'), + ), + 'source' => __('Source'), + 'status' => array( + 'class' => 'TicketStatus', 'desc' => __('Status'), + ), + 'staff' => array( + 'class' => 'Staff', 'desc' => __('Assigned/closing agent'), + ), + 'subject' => 'Subject', + 'team' => array( + 'class' => 'Team', 'desc' => __('Assigned/closing team'), + ), + 'thread' => array( + 'class' => 'TicketThread', 'desc' => __('Ticket Thread'), + ), + 'topic' => array( + 'class' => 'Topic', 'desc' => __('Help topic'), + ), + // XXX: Isn't lastreponse and lastmessage more useful + 'last_update' => array( + 'class' => 'FormattedDate', 'desc' => __('Time of last update'), + ), + 'user' => array( + 'class' => 'User', 'desc' => __('Ticket owner'), + ), + ); + + $extra = VariableReplacer::compileFormScope(TicketForm::getInstance()); + return $base + $extra; + } + //Replace base variables. function replaceVars($input, $vars = array()) { global $ost; diff --git a/include/class.topic.php b/include/class.topic.php index 9daeb7f07dd33b1e6493d4f85dc1b0b062ce834d..776f99e896905946fb5d973b2fc8b930e1104f5a 100644 --- a/include/class.topic.php +++ b/include/class.topic.php @@ -17,7 +17,8 @@ require_once INCLUDE_DIR . 'class.sequence.php'; require_once INCLUDE_DIR . 'class.filter.php'; -class Topic extends VerySimpleModel { +class Topic extends VerySimpleModel +implements TemplateVariable { static $meta = array( 'table' => TOPIC_TABLE, @@ -72,6 +73,22 @@ class Topic extends VerySimpleModel { return $this->getName(); } + static function getVarScope() { + return array( + 'dept' => array( + 'class' => 'Dept', 'desc' => __('Department'), + ), + 'fullname' => __('Help topic full path'), + 'name' => __('Help topic'), + 'parent' => array( + 'class' => 'Topic', 'desc' => __('Parent'), + ), + 'sla' => array( + 'class' => 'SLA', 'desc' => __('Service Level Agreement'), + ), + ); + } + function getId() { return $this->topic_id; } diff --git a/include/class.user.php b/include/class.user.php index 7cb3cf2dfb9ef1855e56c6269a1f125831c3ae4b..960588dae4853e7a8f1952b7c4ac4d3885450104 100644 --- a/include/class.user.php +++ b/include/class.user.php @@ -17,6 +17,7 @@ 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'; class UserEmailModel extends VerySimpleModel { static $meta = array( @@ -188,7 +189,8 @@ class UserCdata extends VerySimpleModel { } } -class User extends UserModel { +class User extends UserModel +implements TemplateVariable { var $_entries; var $_forms; @@ -260,7 +262,7 @@ class User extends UserModel { } function getEmail() { - return $this->default_email->address; + return new EmailAddress($this->default_email->address); } function getFullName() { @@ -289,6 +291,15 @@ class User extends UserModel { return $this->created; } + function getTimezone() { + global $cfg; + + if (($acct = $this->getAccount()) && ($tz = $acct->getTimezone())) { + return $tz; + } + return $cfg->getDefaultTimezone(); + } + function addForm($form, $sort=1, $data=null) { $entry = $form->instanciate($sort, $data); $entry->set('object_type', 'U'); @@ -331,6 +342,20 @@ class User extends UserModel { return $a; } + static function getVarScope() { + $base = array( + 'email' => array( + 'class' => 'EmailAddress', 'desc' => __('Default email address') + ), + 'name' => array( + 'class' => 'PersonsName', 'desc' => 'User name, default format' + ), + 'organization' => array('class' => 'Organization', 'desc' => __('Organization')), + ); + $extra = VariableReplacer::compileFormScope(UserForm::getInstance()); + return $base + $extra; + } + function addDynamicData($data) { return $this->addForm(UserForm::objects()->one(), 1, $data); } @@ -642,7 +667,49 @@ class User extends UserModel { } } -class PersonsName { +class EmailAddress +implements TemplateVariable { + var $address; + + function __construct($address) { + $this->address = $address; + } + + function __toString() { + return $this->address; + } + + function getVar($what) { + require_once PEAR_DIR . 'Mail/RFC822.php'; + require_once PEAR_DIR . 'PEAR.php'; + if (!($mails = Mail_RFC822::parseAddressList($this->address)) || PEAR::isError($mails)) + return ''; + + if (!$list && count($mails) > 1) + return ''; + + $info = $mails[0]; + switch ($what) { + case 'domain': + return $info->host; + case 'personal': + return trim($info->personal, '"'); + case 'mailbox': + return $info->mailbox; + } + } + + static function getVarScope() { + return array( + 'domain' => __('Domain'), + 'mailbox' => __('Mailbox'), + 'personal' => __('Personal name'), + ); + } +} + +class PersonsName +implements TemplateVariable { var $format; var $parts; var $name; @@ -761,6 +828,16 @@ class PersonsName { return $this->__toString(); } + static function getVarScope() { + $formats = array(); + foreach (static::$formats as $name=>$info) { + if (in_array($name, array('original', 'complete'))) + continue; + $formats[$name] = $info[0]; + } + return $formats; + } + function __toString() { @list(, $func) = static::$formats[$this->format]; @@ -976,6 +1053,10 @@ class UserAccountModel extends VerySimpleModel { return $lang; } + function getTimezone() { + return $this->timezone; + } + function save($refetch=false) { // Serialize the extra column on demand if (isset($this->_extra)) { @@ -1220,17 +1301,47 @@ class UserAccountStatus { /* * Generic user list. */ -class UserList extends ListObject { +class UserList extends ListObject +implements TemplateVariable { function __toString() { + return $this->getNames(); + } + function getNames() { $list = array(); foreach($this->storage as $user) { if (is_object($user)) $list [] = $user->getName(); } + return $list ? implode(', ', $list) : ''; + } + function getFull() { + $list = array(); + foreach($this->storage as $user) { + if (is_object($user)) + $list[] = sprintf("%s <%s>", $user->getName(), $user->getEmail()); + } + + return $list ? implode(', ', $list) : ''; + } + + function getEmails() { + $list = array(); + foreach($this->storage as $user) { + if (is_object($user)) + $list[] = $user->getEmail(); + } return $list ? implode(', ', $list) : ''; } + + static function getVarScope() { + return array( + 'names' => __('List of names'), + 'emails' => __('List of email addresses'), + 'full' => __('List of names and email addresses'), + ); + } } ?> diff --git a/include/class.variable.php b/include/class.variable.php index ffb850ec8f264dd2e15ada81e37e90b268c0ccec..3b9e8ce2497492f34436400072c603f6aaa12725 100644 --- a/include/class.variable.php +++ b/include/class.variable.php @@ -21,8 +21,9 @@ class VariableReplacer { var $start_delim; var $end_delim; - var $objects; - var $variables; + var $objects = array(); + var $variables = array(); + var $extras = array(); var $errors; @@ -30,9 +31,6 @@ class VariableReplacer { $this->start_delim = $start_delim; $this->end_delim = $end_delim; - - $this->objects = array(); - $this->variables = array(); } function setError($error) { @@ -65,14 +63,14 @@ class VariableReplacer { if (!$var) { if (method_exists($obj, 'asVar')) - return call_user_func(array($obj, 'asVar')); + return call_user_func(array($obj, 'asVar'), $this); elseif (method_exists($obj, '__toString')) return (string) $obj; } list($v, $part) = explode('.', $var, 2); if ($v && is_callable(array($obj, 'get'.ucfirst($v)))) { - $rv = call_user_func(array($obj, 'get'.ucfirst($v))); + $rv = call_user_func(array($obj, 'get'.ucfirst($v)), $this); if(!$rv || !is_object($rv)) return $rv; @@ -86,7 +84,7 @@ class VariableReplacer { return ""; list($tag, $remainder) = explode('.', $var, 2); - if(($rv = call_user_func(array($obj, 'getVar'), $tag))===false) + if(($rv = call_user_func(array($obj, 'getVar'), $tag, $this))===false) return ""; if(!is_object($rv)) @@ -97,13 +95,21 @@ class VariableReplacer { function replaceVars($input) { + // Preserve existing extras + if ($input instanceof TextWithExtras) + $this->extras = $input->extras; + if($input && is_array($input)) return array_map(array($this, 'replaceVars'), $input); if(!($vars=$this->_parse($input))) return $input; - return str_replace(array_keys($vars), array_values($vars), $input); + $text = str_replace(array_keys($vars), array_values($vars), $input); + if ($this->extras) { + return new TextWithExtras($text, $this->extras); + } + return $text; } function _resolveVar($var) { @@ -113,9 +119,18 @@ class VariableReplacer { return $this->variables[$var]; $parts = explode('.', $var, 2); - if($parts && ($obj=$this->getObj($parts[0]))) - return $this->getVar($obj, $parts[1]); - elseif($parts[0] && @isset($this->variables[$parts[0]])) { //root override + try { + if ($parts && ($obj=$this->getObj($parts[0]))) + return $this->getVar($obj, $parts[1]); + } + catch (OOBContent $content) { + $type = $content->getType(); + $existing = @$this->extras[$type] ?: array(); + $this->extras[$type] = array_merge($existing, $content->getContent()); + return $content->asVar(); + } + + if ($parts[0] && @isset($this->variables[$parts[0]])) { //root override if (is_array($this->variables[$parts[0]]) && isset($this->variables[$parts[0]][$parts[1]])) return $this->variables[$parts[0]][$parts[1]]; @@ -146,5 +161,220 @@ class VariableReplacer { return $vars; } + + static function compileScope($scope, $recurse=5, $exclude=false) { + $items = array(); + foreach ($scope as $name => $info) { + if ($exclude === $name) + continue; + if (isset($info['class']) && $recurse) { + $items[$name] = $info['desc']; + foreach (static::compileScope($info['class']::getVarScope(), $recurse-1, + @$info['exclude'] ?: $name) + as $name2=>$desc) { + $items["{$name}.{$name2}"] = $desc; + } + } + if (!is_array($info)) { + $items[$name] = $info; + } + } + return $items; + } + + static function compileFormScope($form) { + $items = array(); + foreach ($form->getFields() as $f) { + if (!($name = $f->get('name'))) + continue; + if (!$f->isStorable() || !$f->hasData()) + continue; + + $desc = $f->getLocal('label'); + if (($class = $f->asVarType()) && class_exists($class)) { + $desc = array('desc' => $desc, 'class' => $class); + } + $items[$name] = $desc; + foreach (VariableReplacer::compileFieldScope($f) as $name2=>$desc) { + $items["$name.$name2"] = $desc; + } + } + return $items; + } + + static function compileFieldScope($field, $recurse=2, $exclude=false) { + $items = array(); + if (!$field->hasSubFields()) + return $items; + + foreach ($field->getSubFields() as $f) { + if (!($name = $f->get('name'))) + continue; + if ($exclude === $name) + continue; + $items[$name] = $f->getLabel(); + if ($recurse) { + foreach (static::compileFieldScope($f, $recurse-1, $name) + as $name2=>$desc) { + if (($class = $f->asVarType()) && class_exists($class)) { + $desc = array('desc' => $desc, 'class' => $class); + } + $items["$name.$name2"] = $desc; + } + } + } + return $items; + } + + static function getContextForRoot($root) { + switch ($root) { + case 'cannedresponse': + $roots = array('ticket'); + break; + + case 'fa:send_email': + // FIXME: Make this pluggable + require_once INCLUDE_DIR . 'class.filter_action.php'; + return FA_SendEmail::getVarScope(); + + default: + if ($info = Page::getContext($root)) { + $roots = $info; + break; + } + + // Get the context for an email template + if ($tpl_info = EmailTemplateGroup::getTemplateDescription($root)) + $roots = $tpl_info['context']; + } + + if (!$roots) + return false; + + $contextTypes = array( + 'activity' => __('Type of recent activity'), + 'assignee' => array('class' => 'Staff', 'desc' => __('Assigned agent/team')), + 'assigner' => array('class' => 'Staff', 'desc' => __('Agent performing the assignment')), + 'comments' => __('Assign/transfer comments'), + 'link' => __('Access link'), + 'message' => array('class' => 'MessageThreadEntry', 'desc' => 'Message from the EndUser'), + 'note' => array('class' => 'NoteThreadEntry', 'desc' => __('Internal note')), + 'poster' => array('class' => 'User', 'desc' => 'EndUser or Agent originating the message'), + // XXX: This could be EndUser -or- Staff object + 'recipient' => array('class' => 'TicketUser', 'desc' => 'Message recipient'), + 'response' => array('class' => 'ResponseThreadEntry', 'desc' => __('Outgoing response')), + 'signature' => 'Selected staff or department signature', + 'staff' => array('class' => 'Staff', 'desc' => 'Agent originating the activity'), + 'ticket' => array('class' => 'Ticket', 'desc' => 'The ticket'), + 'user' => array('class' => 'User', 'desc' => __('Message recipient')), + ); + $context = array(); + foreach ($roots as $C=>$desc) { + // $desc may be either the root or the description array + if (is_array($desc)) + $context[$C] = $desc; + else + $context[$desc] = $contextTypes[$desc]; + } + $global = osTicket::getVarScope(); + return self::compileScope($context + $global); + } +} + +class PlaceholderList +/* implements TemplateVariable */ { + var $items; + + function __construct($items) { + $this->items = $items; + } + + function asVar() { + $items = array(); + foreach ($this->items as $I) { + if (method_exists($I, 'asVar')) { + $items[] = $I->asVar(); + } + else { + $items[] = (string) $I; + } + } + return implode(',', $items); + } + + function getVar($tag) { + $items = array(); + foreach ($this->items as $I) { + if (is_object($I) && method_exists($I, 'get'.ucfirst($tag))) { + $items[] = call_user_func(array($I, 'get'.ucfirst($tag))); + } + elseif (method_exists($I, 'getVar')) { + $items[] = $I->getVar($tag); + } + } + if (count($items) == 1) { + return $items[0]; + } + return new static(array_filter($items)); + } +} + +/** + * Exception used in the variable replacement process to indicate non text + * content (such as attachments) + */ +class OOBContent extends Exception { + var $type; + var $content; + var $text; + + const FILES = 'files'; + + function __construct($type, $content, $asVar='') { + $this->type = $type; + $this->content = $content; + $this->text = $asVar; + } + + function getType() { return $this->type; } + function getContent() { return $this->content; } + function asVar() { return $this->text; } +} + +/** + * Simple wrapper to represent a rendered or partially rendered template + * with extra content such as attachments + */ +class TextWithExtras { + var $text = ''; + var $extras; + + function __construct($text, array $extras) { + $this->setText($text); + $this->extras = $extras; + } + + function setText($text) { + try { + $this->text = (string) $text; + } + catch (Exception $e) { + throw new InvalidArgumentException('String type is required', 0, $e); + } + } + + function __toString() { + return $this->text; + } + + function getFiles() { + return $this->extras[OOBContent::FILES]; + } +} + +interface TemplateVariable { + // function asVar(); — not absolutely required + // function getVar($name); — not absolutely required + static function getVarScope(); } ?> diff --git a/include/staff/cannedresponse.inc.php b/include/staff/cannedresponse.inc.php index 407c5ba89466beb080c2356f7aa8113546bce33b..8e407d25319dae991eaa3ba51e7e849e7abaf8c3 100644 --- a/include/staff/cannedresponse.inc.php +++ b/include/staff/cannedresponse.inc.php @@ -80,13 +80,13 @@ $info=Format::htmlchars(($errors && $_POST)?$_POST:$info); <font class="error">* <?php echo $errors['response']; ?></font> (<a class="tip" href="#ticket_variables"><?php echo __('Supported Variables'); ?></a>) </div> - <textarea name="response" class="richtext draft draft-delete" cols="21" rows="12" - style="width:98%;" class="richtext draft" <?php + <textarea name="response" cols="21" rows="12" + data-root-context="cannedresponse" + style="width:98%;" class="richtext draft draft-delete" <?php list($draft, $attrs) = Draft::getDraftAndDataAttrs('canned', is_object($canned) ? $canned->getId() : false, $info['response']); echo $attrs; ?>><?php echo $draft ?: $info['response']; ?></textarea> - <br><br> <div><h3><?php echo __('Canned Attachments'); ?> <?php echo __('(optional)'); ?> <i class="help-tip icon-question-sign" href="#canned_attachments"></i></h3> <div class="error"><?php echo $errors['files']; ?></div> diff --git a/include/staff/page.inc.php b/include/staff/page.inc.php index 6326500aba5bd1b6d545eb1f8eae59bb7645cbda..ea373533c886b1b9bd7d11f8cd651e461dca1072 100644 --- a/include/staff/page.inc.php +++ b/include/staff/page.inc.php @@ -145,6 +145,7 @@ if ($page && count($langs) > 1) { ?> lang="<?php echo $cfg->getPrimaryLanguage(); ?>"> <textarea name="body" cols="21" rows="12" style="width:100%" class="richtext draft" <?php + if (!$info['type'] || $info['type'] == 'thank-you') echo 'data-root-context="thank-you"'; list($draft, $attrs) = Draft::getDraftAndDataAttrs('page', $info['id'], $info['body']); echo $attrs; ?>><?php echo $draft ?: $info['body']; ?></textarea> </div> @@ -156,6 +157,7 @@ if ($page && count($langs) > 1) { ?> <div id="translation-<?php echo $tag; ?>" class="tab_content" style="display:none;" lang="<?php echo $tag; ?>"> <textarea name="trans[<?php echo $tag; ?>][body]" cols="21" rows="12" +<?php if ($info['type'] == 'thank-you') echo 'data-root-context="thank-you"'; ?> style="width:100%" class="richtext draft" <?php list($draft, $attrs) = Draft::getDraftAndDataAttrs('page', $info['id'].'.'.$tag, $info['trans'][$tag]); diff --git a/include/staff/templates/content-manage.tmpl.php b/include/staff/templates/content-manage.tmpl.php index 28a016cb71163b256e32a819b8446b832766a60f..3fa4e8fcbd8c1554649c576d84f3ba74e74e4eca 100644 --- a/include/staff/templates/content-manage.tmpl.php +++ b/include/staff/templates/content-manage.tmpl.php @@ -30,9 +30,10 @@ if (count($langs) > 1) { ?> echo Format::htmlchars($info['title']); ?>" /> <div style="margin-top: 5px"> <div class="error"><?php echo $errors['body']; ?></div> - <textarea class="richtext no-bar" name="body"><?php - echo Format::htmlchars(Format::viewableImages($info['body'])); -?></textarea> + <textarea class="richtext no-bar" name="body" + data-root-context="<?php echo $content->getType(); + ?>"><?php echo Format::htmlchars(Format::viewableImages($info['body'])); + ?></textarea> </div> </div> @@ -48,6 +49,7 @@ if (count($langs) > 1) { ?> placeholder="<?php echo __('Title'); ?>" /> <div style="margin-top: 5px"> <textarea class="richtext no-bar" data-direction=<?php echo $nfo['direction']; ?> + data-root-context="<?php echo $content->getType(); ?>" placeholder="<?php echo __('Message content'); ?>" name="trans[<?php echo $tag; ?>][body]"><?php echo Format::htmlchars(Format::viewableImages($trans['body'])); diff --git a/include/staff/ticket-view.inc.php b/include/staff/ticket-view.inc.php index bdc09745da094d2daef7cb944830c7a4a267e9e7..a0e843a0c47e46144ccc66caaf8911ddd758519f 100644 --- a/include/staff/ticket-view.inc.php +++ b/include/staff/ticket-view.inc.php @@ -187,7 +187,7 @@ if($ticket->isOverdue()) </tr> <tr> <th><?php echo __('Create Date');?>:</th> - <td><?php echo Format::datetime($ticket->getCreateDate()); ?></td> + <td><?php echo Format::relativeTime($ticket->getCreateDate()); ?></td> </tr> </table> </td> diff --git a/include/staff/tpl.inc.php b/include/staff/tpl.inc.php index 37e29177e80d850efb54bbd6e8a0c79c891db421..70c9e90b44f6ae456df72c9816992d9a6e881624 100644 --- a/include/staff/tpl.inc.php +++ b/include/staff/tpl.inc.php @@ -99,6 +99,19 @@ $tpl=$msgtemplates[$selected]; <?php } ?> </div> +<?php +$invalid = array(); +if ($template instanceof EmailTemplate) { + if ($invalid = $template->getInvalidVariableUsage()) { + $invalid = array_unique($invalid); ?> + <div class="warning-banner"><?php echo + __('Some variables may not be a valid for this context. Please check for spelling errors and correct usage for this template.') ?> + <br/> + <code><?php echo implode(', ', $invalid); ?></code> +</div> +<?php } +} ?> + <div style="padding-bottom:3px;" class="faded"><strong><?php echo __('Email Subject and Body'); ?>:</strong></div> <div id="toolbar"></div> <div id="save" style="padding-top:5px;"> @@ -108,6 +121,7 @@ $tpl=$msgtemplates[$selected]; </div> <input type="hidden" name="draft_id" value=""/> <textarea name="body" cols="21" rows="16" style="width:98%;" wrap="soft" + data-root-context="<?php echo $selected; ?>" data-toolbar-external="#toolbar" class="richtext draft" <?php list($draft, $attrs) = Draft::getDraftAndDataAttrs('tpl.'.$selected, $tpl_id, $info['body']); echo $attrs; ?>><?php echo $draft ?: $info['body']; diff --git a/js/redactor-osticket.js b/js/redactor-osticket.js index 203c398944384fa5fb620e6cdf5652aa0d8d950e..de2a898253b7910d6c8f583c3023717df1c0e640 100644 --- a/js/redactor-osticket.js +++ b/js/redactor-osticket.js @@ -298,6 +298,9 @@ $(function() { options['plugins'].push('imagepaste'); options.draftDelete = el.hasClass('draft-delete'); } + if (true || 'scp') { // XXX: Add this to SCP only + options['plugins'].push('contexttypeahead'); + } if (el.hasClass('fullscreen')) options['plugins'].push('fullscreen'); if ($('#ticket_thread[data-thread-id]').length) diff --git a/js/redactor-plugins.js b/js/redactor-plugins.js index f41fb48e9cd82e81e93fcbedb1d19a9fe20f2b26..772573bab655b84260ba3b706921f0115a47e254 100644 --- a/js/redactor-plugins.js +++ b/js/redactor-plugins.js @@ -1606,3 +1606,191 @@ RedactorPlugins.imageannotate = function() { } }; }; + +RedactorPlugins.contexttypeahead = function() { + return { + typeahead: false, + context: false, + variables: false, + + init: function() { + if (!this.$element.data('rootContext')) + return; + + this.opts.keyupCallback = this.contexttypeahead.watch.bind(this); + this.opts.keydownCallback = this.contexttypeahead.watch.bind(this); + this.$editor.on('click', this.contexttypeahead.watch.bind(this)); + }, + + watch: function(e) { + var current = this.selection.getCurrent(), + allText = this.$editor.text(), + offset = this.caret.getOffset(), + lhs = allText.substring(0, offset), + search = new RegExp(/%\{([^}]*)$/), + match; + + if (!lhs) { + return !e.isDefaultPrevented(); + } + + if (e.which == 27 || !(match = search.exec(lhs))) + // No longer in a element — close typeahead + return this.contexttypeahead.destroy(); + + if (e.type == 'click') + return; + + // Locate the position of the cursor and the number of characters back + // to the `%{` symbols + var sel = this.selection.get(), + range = this.sel.getRangeAt(0), + content = current.textContent, + clientRects = range.getClientRects(), + position = clientRects[0], + backText = match[1], + parent = this.selection.getParent() || this.$editor, + plugin = this.contexttypeahead; + + // Insert a hidden text input to receive the typed text and add a + // typeahead widget + if (!this.contexttypeahead.typeahead) { + this.contexttypeahead.typeahead = $('<input type="text">') + .css({position: 'absolute', visibility: 'hidden'}) + .width(0).height(position.height - 4) + .appendTo(document.body) + .typeahead({ + property: 'variable', + minLength: 0, + arrow: $('<span class="pull-right"><i class="icon-muted icon-chevron-right"></i></span>') + .css('padding', '0 0 0 6px'), + highlighter: function(variable, item) { + var base = $.fn.typeahead.Constructor.prototype.highlighter + .call(this, variable), + further = new RegExp(variable + '\\.'), + extendable = Object.keys(plugin.variables).some(function(v) { + return v.match(further); + }), + arrow = extendable ? this.options.arrow.clone() : ''; + + return $('<div/>').html(base).prepend(arrow).html() + + $('<span class="faded">') + .text(' — ' + item.desc) + .wrap('<div>').parent().html(); + }, + item: '<li><a href="#" style="display:block"></a></li>', + source: this.contexttypeahead.getContext.bind(this), + sorter: function(items) { + items.sort( + function(a,b) {return a.variable > b.variable ? 1 : -1;} + ); + return items; + }, + matcher: function(item) { + if (item.toLowerCase().indexOf(this.query.toLowerCase()) !== 0) + return false; + + return (this.query.match(/\./g) || []).length == (item.match(/\./g) || []).length; + }, + onselect: this.contexttypeahead.select.bind(this), + scroll: true, + items: 100 + }); + } + + if (position) { + var width = plugin.textWidth( + backText, + this.selection.getParent() || $('<div class="redactor-editor">') + ), + pleft = $(parent).offset().left, + left = position.left - width; + + if (left < pleft) + // This is a bug in chrome, but I'm not sure how to adjust it + left += pleft; + + plugin.typeahead + .css({top: position.top + $(window).scrollTop(), left: left}); + } + + plugin.typeahead + .val(match[1]) + .trigger(e); + + return !e.isDefaultPrevented(); + }, + + getContext: function(typeahead, query) { + var dfd, that=this.contexttypeahead, + root = this.$element.data('rootContext'); + if (!this.contexttypeahead.context) { + dfd = $.Deferred(); + $.ajax('ajax.php/content/context', { + data: {root: root}, + success: function(json) { + var items = $.map(json, function(v,k) { + return {variable: k, desc: v}; + }); + that.variables = json; + dfd.resolve(items); + } + }); + this.contexttypeahead.context = dfd; + } + // Only fetch the context once for this redactor box + this.contexttypeahead.context.then(function(items) { + typeahead.process(items); + }); + }, + + textWidth: function(text, clone) { + var c = $(clone), + o = c.clone().text(text) + .css({'position': 'absolute', 'float': 'left', 'white-space': 'nowrap', 'visibility': 'hidden'}) + .css({'font-family': c.css('font-family'), 'font-weight': c.css('font-weight'), + 'font-size': c.css('font-size')}) + .appendTo($('body')), + w = o.width(); + + o.remove(); + + return w; + }, + + destroy: function() { + if (this.contexttypeahead.typeahead) { + this.contexttypeahead.typeahead.typeahead('hide'); + this.contexttypeahead.typeahead.remove(); + this.contexttypeahead.typeahead = false; + } + }, + + select: function(item) { + var current = this.selection.getCurrent(), + sel = this.selection.get(), + range = this.sel.getRangeAt(0), + cursorAt = range.endOffset, + search = new RegExp(/%\{([^}]*)(\}?)$/); + + // FIXME: ENTER will end up here, but current will be empty + + if (!current) + return; + + // Set cursor at the end of the expanded text + var left = current.textContent.substring(0, cursorAt), + right = current.textContent.substring(cursorAt), + newLeft = left.replace(search, '%{' + item.variable + '}'); + + current.textContent = newLeft + // Drop the remaining part of a variable block, if any + + right.replace(/[^%}]*?[%}]/, ''); + + this.range.setStart(current, newLeft.length - 1); + this.range.setEnd(current, newLeft.length - 1); + this.selection.addRange(); + return this.contexttypeahead.destroy(); + } + }; +}; diff --git a/scp/ajax.php b/scp/ajax.php index 7a6b95fcb3662333969698cfc58ff0527f82b5e7..f34bcfb930f912ea70e6b4592324924ec62fd4a7 100644 --- a/scp/ajax.php +++ b/scp/ajax.php @@ -41,6 +41,7 @@ $dispatcher = patterns('', )), url('^/content/', patterns('ajax.content.php:ContentAjaxAPI', url_get('^log/(?P<id>\d+)', 'log'), + url_get('^context$', 'context'), url_get('^ticket_variables', 'ticket_variables'), url_get('^signature/(?P<type>\w+)(?:/(?P<id>\d+))?$', 'getSignature'), url_get('^(?P<id>\d+)/(?:(?P<lang>\w+)/)?manage$', 'manageContent'), diff --git a/scp/css/scp.css b/scp/css/scp.css index 7817e76560c9610788831f445bd6caa6f4c743e5..279fb557b963772535531845d7744cf91e912bcb 100644 --- a/scp/css/scp.css +++ b/scp/css/scp.css @@ -56,7 +56,8 @@ div#header a { } .faded { - color:#666; + color: #666; + color: rgba(0,0,0,0.5); } .faded-more { color: #aaa; diff --git a/scp/css/typeahead.css b/scp/css/typeahead.css index 981923ab1f4c200b7121f5563171101dfea0a4d2..2e4e4d6ccd71853ecbf21973f302272476fe8dc3 100644 --- a/scp/css/typeahead.css +++ b/scp/css/typeahead.css @@ -7,7 +7,7 @@ float: left; display: none; min-width: 160px; - padding: 4px 0; + padding: 4px 0 2px; margin: 0; list-style: none; background-color: #ffffff; @@ -20,17 +20,24 @@ border-radius: 0 0 5px 5px; -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); -moz-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); - box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); + box-shadow: 0 10px 15px -5px rgba(0, 0, 0, 0.5); -webkit-background-clip: padding-box; -moz-background-clip: padding; background-clip: padding-box; *border-right-width: 2px; *border-bottom-width: 2px; + opacity: 0.95; } .dropdown-menu.pull-right { right: 0; left: auto; } +.dropdown-menu.scroll { + max-height: 180px; + height: auto; + overflow-y: auto; + padding: 0; +} .dropdown-menu .divider { height: 1px; margin: 8px 1px; @@ -42,7 +49,7 @@ } .dropdown-menu a { display: block; - padding: 3px 15px; + padding: 4px 15px; clear: both; font-weight: normal; line-height: 18px; @@ -56,3 +63,12 @@ text-decoration: none; background-color: #0088cc; } +.dropdown-menu li > a:hover .faded, +.dropdown-menu .active > a .faded, +.dropdown-menu .active > a:hover .faded { + color: rgba(255,255,255,0.6); +} + +.dropdown-menu li + li { + border-top: 1px solid rgba(0,0,0,0.15); +} diff --git a/scp/js/bootstrap-typeahead.js b/scp/js/bootstrap-typeahead.js index c274748d808b65d6bd52f3327cc23d42f1f5b563..6c8238ad132a24178d565420083d6ccd0cd27cff 100644 --- a/scp/js/bootstrap-typeahead.js +++ b/scp/js/bootstrap-typeahead.js @@ -28,6 +28,8 @@ this.sorter = this.options.sorter || this.sorter this.highlighter = this.options.highlighter || this.highlighter this.$menu = $(this.options.menu).appendTo('body') + if (this.options.scroll) + this.$menu.addClass('scroll'); this.source = this.options.source this.onselect = this.options.onselect this.strings = true @@ -101,15 +103,15 @@ this.query = this.$element.val() - if (!this.query) { + if (this.query.length < this.options.minLength) { return this.shown ? this.hide() : this } - + items = $.grep(results, function (item) { if (!that.strings) item = item[that.options.property] - if (that.matcher(item)) + if (that.matcher(item)) return item }) @@ -146,6 +148,8 @@ } , highlighter: function (item) { + if (!this.query) + return item; return item.replace(new RegExp('(' + this.query + ')', 'ig'), function ($1, match) { return '<strong>' + match + '</strong>' }) @@ -155,6 +159,7 @@ var that = this items = $(items).map(function (i, item) { + var orig = item; i = $(that.options.item).attr('data-value', JSON.stringify(item)) if (!that.strings) { if(item[that.options.render]) @@ -162,7 +167,7 @@ else item = item[that.options.property]; } - i.find('a').html(that.highlighter(item)) + i.find('a').html(that.highlighter(item, orig)) return i[0] }) @@ -171,6 +176,16 @@ return this } + , adjustScroll: function(next) { + var top = this.$menu.scrollTop(), + bottom = top + this.$menu.height(), + pos = next.position(); + if (pos.top < 0) + this.$menu.scrollTop(top + pos.top - 10); + else if (next.height() + top + pos.top > bottom) + this.$menu.scrollTop(top + pos.top - this.$menu.height() + next.height() + 10); + } + , next: function (event) { var active = this.$menu.find('.active').removeClass('active') , next = active.next() @@ -180,6 +195,10 @@ } next.addClass('active') + + if (this.options.scroll) { + this.adjustScroll(next); + } } , prev: function (event) { @@ -191,6 +210,10 @@ } prev.addClass('active') + + if (this.options.scroll) { + this.adjustScroll(prev); + } } , listen: function () { @@ -283,6 +306,10 @@ $(e.currentTarget).addClass('active') } + , visible: function() { + return this.shown; + } + } @@ -307,6 +334,8 @@ , onselect: null , property: 'value' , render: 'info' + , minLength: 1 + , scroll: false } $.fn.typeahead.Constructor = Typeahead