From c161b59606b80df2b5ce52b6706d6e38fefd5362 Mon Sep 17 00:00:00 2001 From: Jared Hancock <jared@osticket.com> Date: Fri, 20 Mar 2015 18:45:04 -0500 Subject: [PATCH] Implement resend and arbitrary history Add in the ability for an agent to resend a response. Optionally editing the response before sending it, and setting the signature as is possible with the usual responses. When the response is resent, the edited version is marked as GUARDED, and subsequent edits will result in new links in the history chain. That is, when a response is edited and resent by an agent, that response will remain in the history chain. --- include/class.mailer.php | 6 +- include/class.thread.php | 19 ++- include/class.thread_actions.php | 120 +++++++++++++++--- .../templates/thread-entry-edit.tmpl.php | 51 +++++++- .../templates/thread-entry-view.tmpl.php | 45 ++++++- include/staff/ticket-view.inc.php | 2 +- js/redactor-osticket.js | 4 +- scp/js/scp.js | 13 +- 8 files changed, 225 insertions(+), 35 deletions(-) diff --git a/include/class.mailer.php b/include/class.mailer.php index c398df9f5..0abb65246 100644 --- a/include/class.mailer.php +++ b/include/class.mailer.php @@ -361,12 +361,12 @@ class Mailer { 'References' => $options['thread']->getEmailReferences() ); } - elseif ($parent = $options['thread']->getParent()) { + elseif ($original = $options['thread']->findOriginalEmailMessage()) { // Use the parent item as the email information source. This // will apply for staff replies $headers += array( - 'In-Reply-To' => $parent->getEmailMessageId(), - 'References' => $parent->getEmailReferences(), + 'In-Reply-To' => $original->getEmailMessageId(), + 'References' => $original->getEmailReferences(), ); } diff --git a/include/class.thread.php b/include/class.thread.php index 759a03b0c..2b1d8bec7 100644 --- a/include/class.thread.php +++ b/include/class.thread.php @@ -435,6 +435,7 @@ class ThreadEntry extends VerySimpleModel { const FLAG_ORIGINAL_MESSAGE = 0x0001; const FLAG_EDITED = 0x0002; const FLAG_HIDDEN = 0x0004; + const FLAG_GUARDED = 0x0008; // No replace on edit const PERM_EDIT = 'thread.edit'; @@ -471,8 +472,7 @@ class ThreadEntry extends VerySimpleModel { } function getParent() { - if ($this->getPid()) - return ThreadEntry::lookup($this->getPid()); + return $this->parent; } function getType() { @@ -579,6 +579,21 @@ class ThreadEntry extends VerySimpleModel { return $recipients; } + /** + * Recurse through the ancestry of this thread entry to find the first + * thread entry which cites a email Message-ID field. + * + * Returns: + * <ThreadEntry> or null if neither this thread entry nor any of its + * ancestry contains an email header with an email Message-ID header. + */ + function findOriginalEmailMessage() { + $P = $this; + while (!$P->getEmailMessageId() + && ($P = $P->getParent())); + return $P; + } + function getUIDFromEmailReference($ref) { $info = unpack('Vtid/Vuid', diff --git a/include/class.thread_actions.php b/include/class.thread_actions.php index 121ecb8b9..a228139fb 100644 --- a/include/class.thread_actions.php +++ b/include/class.thread_actions.php @@ -60,7 +60,8 @@ class TEA_EditThreadEntry extends ThreadEntryAction { function isVisible() { // Can't edit system posts - return $this->entry->staff_id || $this->entry->user_id; + return ($this->entry->staff_id || $this->entry->user_id) + && $this->entry->type != 'R'; } function isEnabled() { @@ -108,15 +109,13 @@ JS } } - private function trigger__get() { - global $cfg; + protected function trigger__get() { + global $cfg, $thisstaff; include STAFFINC_DIR . 'templates/thread-entry-edit.tmpl.php'; } - private function trigger__post() { - global $thisstaff; - + function updateEntry($guard=false) { $old = $this->entry; $type = ($old->format == 'html') ? 'HtmlThreadEntryBody' : 'TextThreadEntryBody'; @@ -124,7 +123,7 @@ JS if ($new->getClean() == $old->body) // No update was performed - Http::response(201); + return $old; $entry = ThreadEntry::create(array( // Copy most information from the old entry @@ -134,6 +133,9 @@ JS 'type' => $old->type, 'threadId' => $old->thread_id, + // Connect the new entry to be a child of the previous + 'pid' => $old->id, + // Add in new stuff 'title' => $_POST['title'], 'body' => $new, @@ -141,31 +143,49 @@ JS )); if (!$entry) - return $this->trigger__get(); + return false; // Note, anything that points to the $old entry as PID should remain // that way for email header lookups and such to remain consistent - if ($old->flags & ThreadEntry::FLAG_EDITED) { - // Second and further edit --------------- - $original = ThreadEntry::lookup(array('pid'=>$old->id)); + if ($old->flags & ThreadEntry::FLAG_EDITED + and !($old->flags & ThreadEntry::FLAG_GUARDED) + ) { + // Replace previous edit -------------------------- + $original = $old->getParent(); // Drop the previous edit, and base this edit off the original $old->delete(); $old = $original; } - // Mark the new entry as editited (but not hidden) + // Mark the new entry as edited (but not hidden) $entry->flags = ($old->flags & ~ThreadEntry::FLAG_HIDDEN) | ThreadEntry::FLAG_EDITED; + + // Guard against deletes on future edit if requested. This is done + // if an email was triggered by the last edit. In such a case, it + // should not be replace by a subsequent edit. + if ($guard) + $entry->flags |= ThreadEntry::FLAG_GUARDED; + + // Sort in the same place in the thread — XXX: Add a `sequence` id $entry->created = $old->created; $entry->updated = SqlFunction::NOW(); $entry->save(); // Hide the old entry from the object thread - $old->pid = $entry->id; $old->flags |= ThreadEntry::FLAG_HIDDEN; $old->save(); + return $entry; + } + + protected function trigger__post() { + global $thisstaff; + + if (!($entry = $this->updateEntry())) + return $this->trigger__get(); + Http::response('201', JsonDataEncoder::encode(array( 'thread_id' => $this->entry->id, 'new_id' => $entry->id, @@ -177,8 +197,8 @@ ThreadEntry::registerAction(/* trans */ 'Manage', 'TEA_EditThreadEntry'); class TEA_OrigThreadEntry extends ThreadEntryAction { static $id = 'previous'; - static $name = /* trans */ 'View Original'; - static $icon = 'undo'; + static $name = /* trans */ 'View History'; + static $icon = 'copy'; function isVisible() { // Can't edit system posts @@ -199,8 +219,76 @@ class TEA_OrigThreadEntry extends ThreadEntryAction { } private function trigger__get() { - $entry = ThreadEntry::lookup(array('pid'=>$this->entry->getId())); + $entry = $this->entry->getParent(); + if (!$entry) + Http::response(404, 'No history for this entry'); include STAFFINC_DIR . 'templates/thread-entry-view.tmpl.php'; } } ThreadEntry::registerAction(/* trans */ 'Manage', 'TEA_OrigThreadEntry'); + +class TEA_ResendThreadEntry extends TEA_EditThreadEntry { + static $id = 'resend'; + static $name = /* trans */ 'Edit and Resend'; + static $icon = 'reply-all'; + + function isVisible() { + // Can only resend replies + return $this->entry->staff_id && $this->entry->type == 'R'; + } + + protected function trigger__post() { + $resend = @$_POST['commit'] == 'resend'; + + if (!($entry = $this->updateEntry($resend))) + return $this->trigger__get(); + + if (@$_POST['commit'] == 'resend') + $this->resend($entry); + + Http::response('201', JsonDataEncoder::encode(array( + 'thread_id' => $this->entry->id, + 'new_id' => $entry->id, + 'body' => $entry->getBody()->toHtml(), + ))); + } + + function resend($response) { + global $cfg, $thisstaff; + + $vars = $_POST; + $ticket = $response->getThread()->getObject(); + + $dept = $ticket->getDept(); + + if ($thisstaff && $vars['signature'] == 'mine') + $signature = $thisstaff->getSignature(); + elseif ($vars['signature'] == 'dept' && $dept && $dept->isPublic()) + $signature = $dept->getSignature(); + else + $signature = ''; + + $variables = array( + 'response' => $response, + 'signature' => $signature, + 'staff' => $response->getStaff(), + 'poster' => $response->getStaff()); + $options = array('thread' => $response); + + if (($email=$dept->getEmail()) + && ($tpl = $dept->getTemplate()) + && ($msg=$tpl->getReplyMsgTemplate()) + ) { + $msg = $ticket->replaceVars($msg->asArray(), + $variables + array('recipient' => $ticket->getOwner())); + + $attachments = $cfg->emailAttachments() + ? $response->getAttachments() : array(); + $email->send($ticket->getOwner(), $msg['subj'], $msg['body'], + $attachments, $options); + } + // TODO: Add an option to the dialog + $ticket->notifyCollaborators($response, array('signature' => $signature)); + } +} +ThreadEntry::registerAction(/* trans */ 'Manage', 'TEA_ResendThreadEntry'); diff --git a/include/staff/templates/thread-entry-edit.tmpl.php b/include/staff/templates/thread-entry-edit.tmpl.php index 8ebeca0ac..0f1533d70 100644 --- a/include/staff/templates/thread-entry-edit.tmpl.php +++ b/include/staff/templates/thread-entry-edit.tmpl.php @@ -2,14 +2,30 @@ <b><a class="close" href="#"><i class="icon-remove-circle"></i></a></b> <hr/> -<form method="post" action="<?php - echo str_replace('ajax.php/','#',$this->getAjaxUrl()); ?>"> +<form method="post" action="<?php echo $this->getAjaxUrl(true); ?>"> <input type="text" style="width:100%;font-size:14px" placeholder="<?php echo __('Title'); ?>" name="title" value="<?php echo Format::htmlchars($this->entry->title); ?>"/> <hr style="height:0"/> <textarea style="display: block; width: 100%; height: auto; min-height: 150px;" +<?php if ($this->entry->type == 'R') { + $signature = ''; + if (($T = $this->entry->getThread()->getObject()) instanceof Ticket) + $dept = $T->getDept(); + switch ($thisstaff->getDefaultSignatureType()) { + case 'dept': + if ($dept && $dept->canAppendSignature()) + $signature = $dept->getSignature(); + break; + case 'mine': + $signature = $thisstaff->getSignature(); + break; + } ?> + data-dept-id="<?php echo $dept->getId(); ?>" + data-signature-field="signature" + data-signature="<?php echo Format::viewableImages($signature); ?>" +<?php } ?> name="body" class="large <?php if ($cfg->isHtmlThreadEnabled() && $this->entry->format == 'html') @@ -17,16 +33,39 @@ ?>"><?php echo Format::viewableImages($this->entry->body); ?></textarea> +<?php if ($this->entry->type == 'R') { ?> +<div style="margin:10px 0;"><?php echo __('Signature'); ?>: + <label><input type="radio" name="signature" value="none" checked="checked"> <?php echo __('None');?></label> + <?php + if ($thisstaff->getSignature()) {?> + <label><input type="radio" name="signature" value="mine" + <?php echo ($info['signature']=='mine')?'checked="checked"':''; ?>> <?php echo __('My Signature');?></label> + <?php + } ?> + <?php + if ($dept && $dept->canAppendSignature()) { ?> + <label><input type="radio" name="signature" value="dept" + <?php echo ($info['signature']=='dept')?'checked="checked"':''; ?>> + <?php echo sprintf(__('Department Signature (%s)'), Format::htmlchars($dept->getName())); ?></label> + <?php + } ?> +</div> +<?php } # end of type == 'R' ?> + <hr> -<p class="full-width"> +<div class="full-width"> <span class="buttons pull-left"> <input type="button" name="cancel" class="close" value="<?php echo __('Cancel'); ?>"> </span> <span class="buttons pull-right"> - <input type="submit" name="save" - value="<?php echo __('Save Changes'); ?>"> + <button type="submit" name="commit" value="save" class="button" + ><?php echo __('Save'); ?></button> +<?php if ($this->entry->type == 'R') { ?> + <button type="submit" name="commit" value="resend" class="button" + ><?php echo __('Save and Resend'); ?></button> +<?php } ?> </span> -</p> +</div> </form> diff --git a/include/staff/templates/thread-entry-view.tmpl.php b/include/staff/templates/thread-entry-view.tmpl.php index 500aefed1..35c3489c4 100644 --- a/include/staff/templates/thread-entry-view.tmpl.php +++ b/include/staff/templates/thread-entry-view.tmpl.php @@ -2,9 +2,30 @@ <b><a class="close" href="#"><i class="icon-remove-circle"></i></a></b> <hr/> -<div><strong><?php echo Format::htmlchars($entry->title); ?></strong></div> -<div class="thread-body" style="background-color:transparent"> - <?php echo $entry->getBody()->toHtml(); ?> +<div id="history" class="accordian"> + +<?php +$E = $entry; +do { ?> +<dt> + <a href="#"><i class="icon-copy"></i> + <strong><?php echo Format::htmlchars($E->title); ?></strong> + <em><?php if (strpos($E->updated, '0000-') === false) + echo sprintf(__('Edited on %s'), Format::datetime($E->updated)); + else + echo __('Original'); ?></em> + </a> +</dt> +<dd class="hidden"> + <div class="thread-body" style="background-color:transparent"> + <?php echo $E->getBody()->toHtml(); ?> + </div> +</dd> +<?php +} +while (($E = $E->getParent()) && $E->type == $entry->type); +?> + </div> <hr> @@ -16,3 +37,21 @@ </p> </form> + +<script type="text/javascript"> +$(function() { + var I = setInterval(function() { + var A = $('#history.accordian'); + if (!A.length) return; + clearInterval(I); + + var allPanels = $('dd', A).hide().removeClass('hidden'); + $('dt > a', A).click(function() { + $('dt', A).removeClass('active'); + allPanels.slideUp(); + $(this).parent().addClass('active').next().slideDown(); + return false; + }); + allPanels.last().show(); + }, 100); +}); diff --git a/include/staff/ticket-view.inc.php b/include/staff/ticket-view.inc.php index 8e3c73d86..bdc09745d 100644 --- a/include/staff/ticket-view.inc.php +++ b/include/staff/ticket-view.inc.php @@ -429,7 +429,7 @@ $tcount = $ticket->getThreadEntries($types)->count(); ?>" href="#" onclick="javascript: if ($(this).hasClass('disabled')) return false; <?php echo str_replace('"', '\\"', $action->getJsStub()); ?>; return false;"> - <i class="<?php echo $action->getIcon(); ?>"></i> <?php + <i class="icon-fixed-width <?php echo $action->getIcon(); ?>"></i> <?php echo $action->getName(); ?></a></li> <?php } diff --git a/js/redactor-osticket.js b/js/redactor-osticket.js index 69dab8e76..5184801e6 100644 --- a/js/redactor-osticket.js +++ b/js/redactor-osticket.js @@ -156,10 +156,10 @@ RedactorPlugins.signature = function() { else this.$signatureBox.hide(); $('input[name='+$el.data('signatureField')+']', $el.closest('form')) - .on('change', false, false, $.proxy(this.updateSignature, this)); + .on('change', false, false, $.proxy(this.signature.updateSignature, this)); if ($el.data('deptField')) $(':input[name='+$el.data('deptField')+']', $el.closest('form')) - .on('change', false, false, $.proxy(this.updateSignature, this)); + .on('change', false, false, $.proxy(this.signature.updateSignature, this)); // Expand on hover var outer = this.$signatureBox, inner = $('.inner', this.$signatureBox).get(0), diff --git a/scp/js/scp.js b/scp/js/scp.js index 8f99d9545..aed3618a2 100644 --- a/scp/js/scp.js +++ b/scp/js/scp.js @@ -562,16 +562,25 @@ $.dialog = function (url, codes, cb, options) { queue: false, complete: function() { if (options.onshow) options.onshow(); } }); + var submit_button = null; $(document).off('.dialog'); + $(document).on('click.dialog', + '#popup input[type=submit], #popup button[type=submit]', + function(e) { submit_button = $(this); }); $(document).on('submit.dialog', '.dialog#popup form', function(e) { e.preventDefault(); - var $form = $(this); + var $form = $(this), + data = $form.serialize(); + if (submit_button) { + data += '&' + escape(submit_button.attr('name')) + '=' + + escape(submit_button.attr('value')); + } $('div#popup-loading', $popup).show() .find('h1').css({'margin-top':function() { return $popup.height()/3-$(this).height()/3}}); $.ajax({ type: $form.attr('method'), url: 'ajax.php/'+$form.attr('action').substr(1), - data: $form.serialize(), + data: data, cache: false, success: function(resp, status, xhr) { if (xhr && xhr.status && codes -- GitLab