From c62a8c1fd95419d014dccc4572254aef6c191bb6 Mon Sep 17 00:00:00 2001 From: Jared Hancock <jared@osticket.com> Date: Tue, 28 Jul 2015 23:08:47 -0500 Subject: [PATCH] lock: Implement lazy locking system --- include/ajax.config.php | 2 +- include/ajax.tickets.php | 99 ++++--- include/class.ticket.php | 10 +- include/staff/ticket-view.inc.php | 26 +- js/redactor-osticket.js | 32 +-- scp/ajax.php | 8 +- scp/css/scp.css | 58 ++++ scp/images/oscar-avatars.png | Bin 0 -> 10251 bytes scp/js/scp.js | 128 ++++++++- scp/js/ticket.js | 452 +++++++++++++----------------- 10 files changed, 468 insertions(+), 347 deletions(-) create mode 100644 scp/images/oscar-avatars.png diff --git a/include/ajax.config.php b/include/ajax.config.php index 1d9471aba..0bf0a4420 100644 --- a/include/ajax.config.php +++ b/include/ajax.config.php @@ -38,7 +38,7 @@ class ConfigAjaxAPI extends AjaxController { list($primary_sl, $primary_locale) = explode('_', $primary); $config=array( - 'lock_time' => ($cfg->getLockTime()*3600), + 'lock_time' => ($cfg->getLockTime()*60), 'html_thread' => (bool) $cfg->isRichTextEnabled(), 'date_format' => $cfg->getDateFormat(true), 'lang' => $lang, diff --git a/include/ajax.tickets.php b/include/ajax.tickets.php index cf86c4930..c458db823 100644 --- a/include/ajax.tickets.php +++ b/include/ajax.tickets.php @@ -101,20 +101,23 @@ class TicketsAjaxAPI extends AjaxController { } function acquireLock($tid) { - global $cfg,$thisstaff; + global $cfg, $thisstaff; if(!$tid || !is_numeric($tid) || !$thisstaff || !$cfg || !$cfg->getLockTime()) return 0; - if(!($ticket = Ticket::lookup($tid)) || !$ticket->checkStaffPerm($thisstaff)) - return $this->json_encode(array('id'=>0, 'retry'=>false, 'msg'=>__('Lock denied!'))); + if (!($ticket = Ticket::lookup($tid)) || !$ticket->checkStaffPerm($thisstaff)) + return $this->encode(array('id'=>0, 'retry'=>false, 'msg'=>__('Lock denied!'))); //is the ticket already locked? - if($ticket->isLocked() && ($lock=$ticket->getLock()) && !$lock->isExpired()) { + if ($ticket->isLocked() && ($lock=$ticket->getLock()) && !$lock->isExpired()) { /*Note: Ticket->acquireLock does the same logic...but we need it here since we need to know who owns the lock up front*/ //Ticket is locked by someone else.?? - if($lock->getStaffId()!=$thisstaff->getId()) - return $this->json_encode(array('id'=>0, 'retry'=>false, 'msg'=>__('Unable to acquire lock.'))); + if ($lock->getStaffId() != $thisstaff->getId()) + return $this->json_encode(array('id'=>0, 'retry'=>false, + 'msg' => sprintf(__('Currently locked by %s'), + $lock->getStaffName()) + )); //Ticket already locked by staff...try renewing it. $lock->renew(); //New clock baby! @@ -130,54 +133,60 @@ class TicketsAjaxAPI extends AjaxController { )); } - function renewLock($tid, $id) { + function renewLock($id, $ticketId) { global $thisstaff; - if(!$tid || !is_numeric($tid) || !$id || !is_numeric($id) || !$thisstaff) - return $this->json_encode(array('id'=>0, 'retry'=>true)); - - if (!($ticket = Ticket::lookup($tid))) - return $this->json_encode(array('id'=>0, 'retry'=>true)); - - $lock = $ticket->getLock(); - if(!$lock || !$lock->getStaffId() || $lock->isExpired()) //Said lock doesn't exist or is is expired - return self::acquireLock($tid); //acquire the lock - - if($lock->getStaffId()!=$thisstaff->getId()) //user doesn't own the lock anymore??? sorry...try to next time. - return $this->json_encode(array('id'=>0, 'retry'=>false)); //Give up... - - //Renew the lock. - $lock->renew(); //Failure here is not an issue since the lock is not expired yet.. client need to check time! - - return $this->json_encode(array('id'=>$lock->getId(), 'time'=>$lock->getTime())); + if (!$id || !is_numeric($id) || !$thisstaff) + Http::response(403, $this->encode(array('id'=>0, 'retry'=>false))); + if (!($lock = Lock::lookup($id))) + Http::response(404, $this->encode(array('id'=>0, 'retry'=>'acquire'))); + if (!($ticket = Ticket::lookup($ticketId)) || $ticket->lock_id != $lock->lock_id) + // Ticket / Lock mismatch + Http::response(400, $this->encode(array('id'=>0, 'retry'=>false))); + + if (!$lock->getStaffId() || $lock->isExpired()) + // Said lock doesn't exist or is is expired — fetch a new lock + return self::acquireLock($ticket->getId()); + + if ($lock->getStaffId() != $thisstaff->getId()) + // user doesn't own the lock anymore??? sorry...try to next time. + Http::response(403, $this->encode(array('id'=>0, 'retry'=>false, + 'msg' => sprintf(__('Currently locked by %s'), + $lock->getStaffName()) + ))); //Give up... + + // Ensure staff still has access + if (!$ticket->checkStaffPerm($thisstaff)) + Http::response(403, $this->encode(array('id'=>0, 'retry'=>false, + 'msg' => sprintf(__('You no longer have access to #%s.'), + $ticket->getNumber()) + ))); + + // Renew the lock. + // Failure here is not an issue since the lock is not expired yet.. client need to check time! + $lock->renew(); + + return $this->encode(array('id'=>$lock->getId(), 'time'=>$lock->getTime(), + 'code' => $lock->getCode())); } - function releaseLock($tid, $id=0) { + function releaseLock($id) { global $thisstaff; - if (!($ticket = Ticket::lookup($tid))) { + if (!$id || !is_numeric($id) || !$thisstaff) + Http::response(403, $this->encode(array('id'=>0, 'retry'=>true))); + if (!($lock = Lock::lookup($id))) + Http::response(404, $this->encode(array('id'=>0, 'retry'=>true))); + + // You have to own the lock + if ($lock->getStaffId() != $thisstaff->getId()) { return 0; } - - if ($id) { - // Fetch the lock from the ticket - if (!($lock = $ticket->getLock())) { - return 1; - } - // Identify the lock by the ID number - if ($lock->getId() != $id) { - return 0; - } - // You have to own the lock - if ($lock->getStaffId() != $thisstaff->getId()) { - return 0; - } - // Can't be expired - if ($lock->isExpired()) { - return 1; - } - return $lock->release() ? 1 : 0; + // Can't be expired + if ($lock->isExpired()) { + return 1; } + return $lock->release() ? 1 : 0; return Lock::removeStaffLocks($thisstaff->getId(), $ticket) ? 1 : 0; } diff --git a/include/class.ticket.php b/include/class.ticket.php index 725bf154f..6a6af2ce1 100644 --- a/include/class.ticket.php +++ b/include/class.ticket.php @@ -542,10 +542,16 @@ implements RestrictedAccess, Threadable { } function getLock() { - return $this->lock; + $lock = $this->lock; + if ($lock && !$lock->isExpired()) + return $lock; } - function acquireLock($staffId, $lockTime) { + function acquireLock($staffId, $lockTime=null) { + global $cfg; + + if (!isset($lockTime)) + $lockTime = $cfg->getLockTime(); if (!$staffId or !$lockTime) //Lockig disabled? return null; diff --git a/include/staff/ticket-view.inc.php b/include/staff/ticket-view.inc.php index 51e1fe128..f1a4560b7 100644 --- a/include/staff/ticket-view.inc.php +++ b/include/staff/ticket-view.inc.php @@ -8,10 +8,6 @@ if(!@$thisstaff->isStaff() || !$ticket->checkStaffPerm($thisstaff)) die('Access //Re-use the post info on error...savekeyboards.org (Why keyboard? -> some people care about objects than users!!) $info=($_POST && $errors)?Format::input($_POST):array(); -//Auto-lock the ticket if locking is enabled.. If already locked by the user then it simply renews. -if($cfg->getLockTime() && !$ticket->acquireLock($thisstaff->getId(),$cfg->getLockTime())) - $warn.=__('Unable to obtain a lock on the ticket'); - //Get the goodies. $dept = $ticket->getDept(); //Dept $role = $thisstaff->getRole($dept); @@ -448,7 +444,8 @@ $tcount = $ticket->getThreadEntries($types)->count(); <div id="msg_warning"><?php echo $warn; ?></div> <?php } ?> -<div class="sticky bar stop actions" id="response_options"> +<div class="sticky bar stop actions" id="response_options" +> <ul class="tabs"> <?php if ($role->hasPerm(TicketModel::PERM_REPLY)) { ?> @@ -470,7 +467,10 @@ $tcount = $ticket->getThreadEntries($types)->count(); </ul> <?php if ($role->hasPerm(TicketModel::PERM_REPLY)) { ?> - <form id="reply" class="tab_content spellcheck" action="tickets.php?id=<?php + <form id="reply" class="tab_content spellcheck exclusive" + data-lock-object-id="ticket/<?php echo $ticket->getId(); ?>" + data-lock-id="<?php echo ($mylock) ? $mylock->getId() : ''; ?>" + action="tickets.php?id=<?php echo $ticket->getId(); ?>" name="reply" method="post" enctype="multipart/form-data"> <?php csrf_token(); ?> <input type="hidden" name="id" value="<?php echo $ticket->getId(); ?>"> @@ -1024,19 +1024,5 @@ $(function() { } }); }); -<?php - // Set the lock if one exists - if ($mylock) { ?> -!function() { - var setLock = setInterval(function() { - if (typeof(window.autoLock) === 'undefined') - return; - clearInterval(setLock); - autoLock.setLock({ - id:<?php echo $mylock->getId(); ?>, - time: <?php echo $cfg->getLockTime() * 60; ?>}, 'acquire'); - }, 50); -}(); -<?php } ?> }); </script> diff --git a/js/redactor-osticket.js b/js/redactor-osticket.js index 8c3a8d806..3b8529754 100644 --- a/js/redactor-osticket.js +++ b/js/redactor-osticket.js @@ -68,15 +68,6 @@ RedactorPlugins.draft = function() { this.$box.trigger('draft:recovered'); }, afterUpdateDraft: function(name, data) { - // Slight workaround. Signal the 'keyup' event normally signaled - // from typing in the <textarea> - if ($.autoLock - && this.$box.closest('form').find('input[name=lockCode]').val() - && this.code.get() - ) { - $.autoLock.handleEvent(); - } - // If the draft was created, a draft_id will be sent back — update // the URL to send updates in the future if (!this.opts.draftId && data.draft_id) { @@ -145,6 +136,19 @@ RedactorPlugins.draft = function() { }; }; +RedactorPlugins.autolock = function() { + return { + init: function() { + var code = this.$box.closest('form').find('[name=lockCode]'), + self = this; + if (code.length) + this.opts.keydownCallback = function(e) { + self.$box.closest('[data-lock-object-id]').exclusive('acquire'); + }; + } + }; +} + RedactorPlugins.signature = function() { return { init: function() { @@ -216,16 +220,6 @@ RedactorPlugins.signature = function() { } }; -RedactorPlugins.autolock = function() { - return { - init: function() { - var code = this.$box.closest('form').find('[name=lockCode]'); - if ($.autoLock && code.length) - this.opts.keydownCallback = $.autoLock.handleEvent; - } - }; -} - /* Redactor richtext init */ $(function() { var captureImageSizes = function(html) { diff --git a/scp/ajax.php b/scp/ajax.php index c2ff371d5..5df6b35b9 100644 --- a/scp/ajax.php +++ b/scp/ajax.php @@ -137,15 +137,17 @@ $dispatcher = patterns('', url_get('^/(?P<id>\d+)/forms/manage$', 'manageForms'), url_post('^/(?P<id>\d+)/forms/manage$', 'updateForms') )), + url('^/lock/', patterns('ajax.tickets.php:TicketsAjaxAPI', + url_post('^ticket/(?P<tid>\d+)$', 'acquireLock'), + url_post('^(?P<id>\d+)/ticket/(?P<tid>\d+)/renew', 'renewLock'), + url_post('^(?P<id>\d+)/release', 'releaseLock') + )), url('^/tickets/', patterns('ajax.tickets.php:TicketsAjaxAPI', url_get('^(?P<tid>\d+)/change-user$', 'changeUserForm'), url_post('^(?P<tid>\d+)/change-user$', 'changeUser'), url_get('^(?P<tid>\d+)/user$', 'viewUser'), url_post('^(?P<tid>\d+)/user$', 'updateUser'), url_get('^(?P<tid>\d+)/preview', 'previewTicket'), - url_post('^(?P<tid>\d+)/lock$', 'acquireLock'), - url_post('^(?P<tid>\d+)/lock/(?P<id>\d+)/renew', 'renewLock'), - url_post('^(?P<tid>\d+)/lock/(?P<id>\d+)/release', 'releaseLock'), url_get('^(?P<tid>\d+)/forms/manage$', 'manageForms'), url_post('^(?P<tid>\d+)/forms/manage$', 'updateForms'), url_get('^(?P<tid>\d+)/canned-resp/(?P<cid>\w+).(?P<format>json|txt)', 'cannedResponse'), diff --git a/scp/css/scp.css b/scp/css/scp.css index dee26d771..00f782692 100644 --- a/scp/css/scp.css +++ b/scp/css/scp.css @@ -1831,6 +1831,11 @@ button[type=submit]:hover, input[type=submit]:hover, input[type=submit]:active { background-color: rgba(0, 0, 0, 0.5); } +.button:disabled, .action-button:disabled, +button[type=submit]:disabled, input[type=submit]:disabled { + opacity: 0.6; +} + .save.pending:hover { box-shadow: 0 0 0 2px rgba(242, 165, 0, 1) inset; background-color: rgba(255, 174, 0, 0.79); @@ -2502,6 +2507,59 @@ td.indented { } } +.message.bar { + position: fixed; + top: 0; + left: 0; + right: 0; + padding: 9px 15px; + z-index: 10; + background-color: white; + box-shadow: 0 3px 10px rgba(0,0,0,0.2); + opacity: 0.95; +} +.message.bar.bottom { + bottom: 0; + top: auto; + box-shadow: 0 -3px 10px rgba(0,0,0,0.2); +} +.message.bar .avatar { + display: inline-block; + width: 36px; + height: 36px; + margin-right: 10px; + background-image: url(../images/oscar-avatars.png); + background-repeat: no-repeat; + background-size: 180px 72px; +} +.avatar.oscar-boy { + background-position: -72px 0; +} +.avatar.oscar-borg { + background-position: 0 -36px; +} +.message.bar .title { + font-weight: bold; + font-size: 1.1em; +} +.message.bar .body { + margin-left: 42px; +} +.message.bar.warning { + border-bottom: 3px solid orange; +} +.message.bar.bottom.warning { + border-bottom: none; + border-top: 3px solid orange; +} +.message.bar.danger { + border-bottom: 3px solid red; +} +.message.bar.bottom.danger { + border-bottom: none; + border-top: 3px solid red; +} + #thread-items::before { border-left: 2px dotted #ddd; border-bottom-color: rgba(0,0,0,0.1); diff --git a/scp/images/oscar-avatars.png b/scp/images/oscar-avatars.png new file mode 100644 index 0000000000000000000000000000000000000000..624c0420ab336fb6391b7288b869526bf9bc55df GIT binary patch literal 10251 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 literal 0 HcmV?d00001 diff --git a/scp/js/scp.js b/scp/js/scp.js index 19a0e1376..6a06173c9 100644 --- a/scp/js/scp.js +++ b/scp/js/scp.js @@ -758,6 +758,131 @@ $.orgLookup = function (url, cb) { $.uid = 1; ++function($) { + var MessageBar = function() { + this.defaults = { + avatar: 'oscar-boy', + bar: '<div class="message bar"></div>', + button: '<button type="button" class="inline button"></button>', + buttonClass: '', + buttonText: __('OK'), + classes: '', + dismissible: true, + onok: null, + position: 'top' + }; + + this.show = function(title, message, options) { + this.hide(); + options = $.extend({}, this.defaults, options); + var bar = this.bar = $(options.bar).addClass(options.classes) + .append($('<div class="title"></div>').text(title)) + .append($('<div class="body"></div>').text(message)) + .addClass(options.position); + if (options.avatar) + bar.prepend($('<div class="avatar pull-left" title="Oscar"></div>') + .addClass(options.avatar)); + + if (options.onok || options.dismissible) { + bar + .prepend($('<div><div class="valign-helper"></div></div>') + // FIXME: This is not compatible with .rtl + .css({position: 'absolute', top: 0, bottom: 0, right: 0, margin: '0 15px'}) + .append($(options.button) + .text(options.buttonText) + .click(this.dismiss.bind(this)) + .addClass(options.buttonClass) + ) + ); + } + this.visible = true; + this.options = options; + + $('body').append(bar); + this.height = bar.height(); + + // Slight slide in + if (options.position == 'bottom') { + bar.css('bottom', -this.height/2).animate({'bottom': 0}); + } + // Otherwise assume TOP positioning + else { + var hovering = false, + y = $(window).scrollTop(), + targetY = (y < this.height) ? -this.height - 10 + y : 0; + bar.css('top', -this.height/2).animate({'top': targetY}); + + // Plop out on mouse hover + bar.hover(function() { + if (!hovering && this.visible && bar.css('top') != '0') { + bar.stop().animate({'margin-top': -parseInt(bar.css('top'), 10)}, 400, 'easeOutBounce'); + hovering = true; + } + }.bind(this), function() { + if (this.visible && hovering) { + bar.stop().animate({'margin-top': 0}); + hovering = false; + } + }.bind(this)); + } + + return bar; + }; + + this.scroll = function(event) { + // Shade on scroll to top + if (!this.visible || this.options.position != 'top') + return; + var y = $(window).scrollTop(); + if (y < this.height) { + this.bar.css({top: -this.height -10 + y}); + this.shading = true; + } + else if (this.bar.css('top') != '0') { + if (this.shading) { + this.bar.stop().animate({top: 0}); + this.shading = false; + } + } + }; + + this.dismiss = function(event) { + if (this.options.onok) { + this.bar.find('button').replaceWith( + $('<i class="icon-spinner icon-spin icon-large"></i>') + ); + if (this.options.onok(event) === false) + return; + } + this.hide(); + }; + + this.hide = function() { + if (!this.bar || !this.visible) + return; + var bar = this.bar.removeAttr('style'); + var dir = this.options.position == 'bottom' ? 'down' : 'up'; + // NOTE: destroy() is not called here because a new bar might be + // created before the animation finishes + bar.hide("slide", { direction: dir }, 400, function() { bar.remove(); }); + this.visible = false; + }; + + this.destroy = function() { + if (!this.bar || !this.visible) + return; + this.bar.remove(); + this.visible = false; + }; + + // Destroy on away navigation + $(document).on('pjax:start.messageBar', this.destroy.bind(this)); + $(window).on('scroll.messageBar', this.scroll.bind(this)); + }; + + $.messageBar = new MessageBar(); +}(window.jQuery); + // Tabs $(document).on('click.tab', 'ul.tabs > li > a', function(e) { e.preventDefault(); @@ -853,9 +978,6 @@ getConfig = (function() { })(); $(document).on('pjax:click', function(options) { - // Release ticket lock (maybe) - if ($.autoLock !== undefined) - $.autoLock.releaseLock(); // Stop all animations $(document).stop(false, true); diff --git a/scp/js/ticket.js b/scp/js/ticket.js index 4cfb20a7c..9a54a7cee 100644 --- a/scp/js/ticket.js +++ b/scp/js/ticket.js @@ -13,282 +13,227 @@ vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ -var autoLock = { - - // Defaults - lockId: 0, - lockCode: '', - timerId: 0, - lasteventTime: 0, - lastcheckTime: 0, - lastattemptTime: 0, - acquireTime: 0, - renewTime: 0, - renewFreq: 10000, //renewal frequency in seconds...based on returned lock time. - time: 0, - lockAttempts: 0, //Consecutive lock attempt errors - maxattempts: 2, //Maximum failed lock attempts before giving up. - warn: true, - retry: true, - - addEvent: function(elm, evType, fn, useCapture) { - if(elm.addEventListener) { - elm.addEventListener(evType, fn, useCapture); - return true; - }else if(elm.attachEvent) { - return elm.attachEvent('on' + evType, fn); - }else{ - elm['on' + evType] = fn; - } ++function( $ ) { + var Lock = function(element, options) { + this.$element = $(element); + this.options = $.extend({}, $.fn.exclusive.defaults, options); + if (!this.$element.data('lockObjectId')) + return; + this.objectId = this.$element.data('lockObjectId'); + this.lockId = options.lockId || this.$element.data('lockId') || undefined; + this.fails = 0; + this.setup(); + } + + Lock.prototype = { + constructor: Lock, + + setup: function() { + // When something inside changes or is clicked which requires a lock, + // attempt to fetch one (lazily) + $(':input', this.$element).on('keyup, change', this.acquire.bind(this)); + $(':submit', this.$element).click(this.ensureLocked.bind(this)); + + // If lock already held, assume full time of lock remains, but warn + // user about pending expiration + if (this.lockId) { + getConfig().then(function(c) { + this.lockTimeout(c.lock_time - 20); + }.bind(this)); + } }, - removeEvent: function(elm, evType, fn, useCapture) { - if(elm.removeEventListener) { - elm.removeEventListener(evType, fn, useCapture); - return true; - }else if(elm.detachEvent) { - return elm.detachEvent('on' + evType, fn); - }else { - elm['on' + evType] = null; - } - }, - - //Incoming event... - handleEvent: function(e) { - - if(autoLock.lockId && !autoLock.lasteventTime) { //I hate nav away warnings..but - $(document).on('pjax:beforeSend.changed', function(e) { - return confirm(__("Any changes or info you've entered will be discarded!")); - }); - $(window).bind('beforeunload', function(e) { - return __("Any changes or info you've entered will be discarded!"); - }); - } - // Handle events only every few seconds - var now = new Date().getTime(), - renewFreq = autoLock.renewFreq; - - if (autoLock.lasteventTime && now - autoLock.lasteventTime < renewFreq) - return; - - autoLock.lasteventTime = now; - - if (!autoLock.lockId) { - // Retry every 5 seconds?? - if (autoLock.retry) - autoLock.acquireLock(e,autoLock.warn); - } else { - autoLock.renewLock(e); - } - + acquire: function() { + if (this.lockId) + return this.renew(); + if (this.nextRenew && new Date().getTime() < this.nextRenew) + return this.locked; + if (this.ajaxActive) + return this.locked; + + this.ajaxActive = $.ajax({ + type: "POST", + url: 'ajax.php/lock/'+this.objectId, + dataType: 'json', + cache: false, + success: $.proxy(this.update, this), + error: $.proxy(this.retry, this, this.acquire), + complete: $.proxy(function() { this.ajaxActive = false; }, this) + }); + return this.locked = $.Deferred(); }, - //Watch activity on individual form. - watchForm: function(fObj,fn) { - if(!fObj || !fObj.length) - return; - - //Watch onSubmit event on the form. - autoLock.addEvent(fObj,'submit',autoLock.onSubmit,true); - //Watch activity on text + textareas + select fields. - for (var i=0; i<fObj.length; i++) { - switch(fObj[i].type) { - case 'textarea': - case 'text': - autoLock.addEvent(fObj[i],'keyup',autoLock.handleEvent,true); - break; - case 'select-one': - case 'select-multiple': - if(fObj.name!='reply') //Bug on double ajax call since select make it's own ajax call. TODO: fix it - autoLock.addEvent(fObj[i],'change',autoLock.handleEvent,true); - break; - default: - } - } + renew: function() { + if (!this.lockId) + return; + if (this.nextRenew && new Date().getTime() < this.nextRenew) + return this.locked; + if (this.ajaxActive) + return this.locked; + + this.ajaxActive = $.ajax({ + type: "POST", + url: 'ajax.php/lock/{0}/{1}/renew'.replace('{0}',this.lockId).replace('{1}',this.objectId), + dataType: 'json', + cache: false, + success: $.proxy(this.update, this), + error: $.proxy(this.retry, this, this.renew), + complete: $.proxy(function() { this.ajaxActive = false; }, this) + }); + return this.locked = $.Deferred(); }, - //Watch all the forms on the document. - watchDocument: function() { - - //Watch forms of interest only. - for (var i=0; i<document.forms.length; i++) { - if(!document.forms[i].id.value || parseInt(document.forms[i].id.value)!=autoLock.tid) - continue; - autoLock.watchForm(document.forms[i],autoLock.checkLock); - } + wakeup: function(e) { + // Click handler from message bar. Bar will be manually hidden when + // lock is re-acquired + this.renew(); + return false; }, - Init: function(config) { - - //make sure we are on ticket view page & locking is enabled! - var fObj=$('form#note'); - if(!fObj - || !$(':input[name=id]',fObj).length - || !$(':input[name=locktime]',fObj).length - || $(':input[name=locktime]',fObj).val()==0) { - return; - } - - void(autoLock.tid=parseInt($(':input[name=id]',fObj).val())); - void(autoLock.lockTime=parseInt($(':input[name=locktime]',fObj).val())); - - autoLock.watchDocument(); - autoLock.addEvent(window,'unload',autoLock.releaseLock,true); //Release lock regardless of any activity. + retry: function(func, xhr, textStatus, response) { + var json = xhr ? xhr.responseJSON : response; + + if ((typeof json == 'object' && !json.retry) || !this.options.retry) + return this.fail(json.msg); + if (typeof json == 'object' && json.retry == 'acquire') { + // Lock no longer exists server-side + this.destroy(); + setTimeout(this.acquire.bind(this), 2); + } + if (++this.fails > this.options.maxRetries) + // Attempt to acquire a new lock ? + return this.fail(json ? json.msg : null); + this.retryTimer = setTimeout($.proxy(func, this), this.options.retryInterval * 1000); }, - - onSubmit: function(e) { - if(e.type=='submit') { //Submit. double check! - //remove nav away warning if any. - $(window).unbind('beforeunload'); - //Only warn if we had a failed lock attempt. - if(autoLock.warn && !autoLock.lockId && autoLock.lasteventTime) { - var answer=confirm(__('Unable to acquire a lock on the ticket. Someone else could be working on the same ticket. Please confirm if you wish to continue anyways.')); - if(!answer) { - e.returnValue=false; - e.cancelBubble=true; - if(e.preventDefault) { - e.preventDefault(); - } - return false; - } - } - } - return true; + release: function() { + if (!this.lockId) + return false; + if (this.ajaxActive) + this.ajaxActive.abort(); + + $.ajax({ + type: 'POST', + url: 'ajax.php/lock/{0}/release'.replace('{0}', this.lockId), + data: 'delete', + async: false, + cache: false, + always: $.proxy(this.destroy, this) + }); }, - acquireLock: function(e,warn) { - - if(!autoLock.tid) { return false; } - - var warn = warn || false; - - if(autoLock.lockId) { - autoLock.renewLock(e); - } else { - $.ajax({ - type: "POST", - url: 'ajax.php/tickets/'+autoLock.tid+'/lock', - dataType: 'json', - cache: false, - success: function(lock){ - autoLock.setLock(lock,'acquire',warn); - } - }); - } - - return autoLock.lockId; + destroy: function() { + clearTimeout(this.warning); + clearTimeout(this.retryTimer); + $(window).off('.exclusive'); + delete this.lockId; + $(this.options.lockInput, this.$element).val(''); }, - //Renewal only happens on form activity.. - renewLock: function(e) { - - if (!autoLock.lockId) - return false; - - var now = new Date().getTime(), - renewFreq = autoLock.renewFreq; - - if (autoLock.lastcheckTime && now - autoLock.lastcheckTime < renewFreq) - return; - - autoLock.lastcheckTime = now; - $.ajax({ - type: 'POST', - url: 'ajax.php/tickets/'+autoLock.tid+'/lock/'+autoLock.lockId+'/renew', - dataType: 'json', - cache: false, - success: function(lock){ - autoLock.setLock(lock,'renew',autoLock.warn); - } - }); + update: function(lock) { + if (typeof lock != 'object' || lock.retry === true) { + // Non-json response, or retry requested server-side + return this.retry(this.renew, this.activeAjax, false, lock); + } + if (!lock.id) { + // Response did not include a lock id number + return this.fail(lock.msg); + } + if (!this.lockId) { + // Set up release on away navigation + $(window).off('.exclusive'); + $(window).on('pjax:click.exclusive', $.proxy(this.release, this)); + } + + this.lockId = lock.id; + this.fails = 0; + $.messageBar.hide(); + this.errorBar = false; + + // If there is an input with the name 'lockCode', then set the value + // to the lock.code retrieved (if any) + if (lock.code) + $(this.options.lockInput, this.$element).val(lock.code); + + // Deadband renew to every 30 seconds + this.nextRenew = new Date().getTime() + 30000; + + // Warn 10 seconds before expiration + this.lockTimeout(lock.time - 10); + + if (this.locked) + this.locked.resolve(lock); }, - releaseLock: function(e) { - if (!autoLock.tid || !autoLock.lockId) { return false; } - - $.ajax({ - type: 'POST', - url: 'ajax.php/tickets/'+autoLock.tid+'/lock/'+autoLock.lockId+'/release', - data: 'delete', - async: false, - cache: false, - success: function() { - autoLock.destroy(); - } - }); + lockTimeout: function(time) { + if (this.warning) + clearTimeout(this.warning); + this.warning = setTimeout(this.warn.bind(this), time * 1000); }, - setLock: function(lock, action, warn) { - var warn = warn || false; - - if (!lock) - return false; - - autoLock.lockId=lock.id; //override the lockid. - - if (lock.code) { - autoLock.lockCode = lock.code; - // Update the lock code for the upcoming POST - var el = $('input[name=lockCode]').val(lock.code); - } - - switch(action){ - case 'renew': - if(!lock.id && lock.retry) { - autoLock.lockAttempts=1; //reset retries. - autoLock.acquireLock(e,false); //We lost the lock?? ..try to re acquire now. - } - break; - case 'acquire': - if(!lock.id) { - autoLock.lockAttempts++; - if(warn && (!lock.retry || autoLock.lockAttempts>=autoLock.maxattempts)) { - autoLock.retry=false; - alert(__('Unable to lock the ticket. Someone else could be working on the same ticket.')); - } - } - break; - } - - if (lock.id && lock.time) { - autoLock.resetTimer((lock.time - 10) * 1000); - } + ensureLocked: function(e) { + // Make sure a lock code has been fetched first + if (!$(this.options.lockInput, this.$element).val()) { + var $target = $(e.target), + text = $target.text() || $target.val(); + $target.prop('disabled', true).text(__('Acquiring Lock')).val(__('Acquiring Lock')); + this.acquire().always(function(lock) { + $target.text(text).val(text).prop('disabled', false); + if (typeof lock == 'object' && lock.code) + $target.trigger(e.type, e); + }.bind(this)); + return false; + } }, - discardWarning: function(e) { - e.returnValue=__("Any changes or info you've entered will be discarded!"); + warn: function() { + $.messageBar.show( + __('Your lock is expiring soon.'), + __('The lock you hold on this ticket will expire soon. Would you like to renew the lock?'), + {onok: this.wakeup.bind(this), buttonText: __("Renew")} + ).addClass('warning'); }, - //TODO: Monitor events and elapsed time and warn user when the lock is about to expire. - monitorEvents: function() { - $.sysAlert( - __('Your lock is expiring soon'), - __('The lock you hold on this ticket will expire soon. Would you like to renew the lock?'), - function() { - autoLock.renewLock(); - } - ); - }, + fail: function(msg) { + // Don't retry for 5 seconds + this.nextRenew = new Date().getTime() + 5000; + // Resolve anything awaiting + if (this.locked) + this.locked.rejectWith(msg); + // No longer locked + this.destroy(); + // Flash the error bar if it's already on the screen + if (this.errorBar && $.messageBar.visible) + return this.errorBar.effect('highlight'); + // Add the error bar to the screen + this.errorBar = $.messageBar.show( + msg || __('Unable to lock the ticket.'), + __('Someone else could be working on the same ticket.'), + {avatar: 'oscar-borg', buttonClass: 'red', dismissible: true} + ).addClass('danger'); + } + }; + + $.fn.exclusive = function ( option ) { + return this.each(function () { + var $this = $(this), + data = $this.data('exclusive'), + options = typeof option == 'object' && option; + if (!data) $this.data('exclusive', (data = new Lock(this, options))); + if (typeof option == 'string') data[option](); + }); + }; - clearTimer: function() { - clearTimeout(autoLock.timerId); - }, + $.fn.exclusive.defaults = { + lockInput: 'input[name=lockCode]', + maxRetries: 2, + retry: true, + retryInterval: 2 + }; - resetTimer: function(time) { - autoLock.clearTimer(); - autoLock.timerId = setTimeout( - function () { autoLock.monitorEvents(); }, - time || 30000 - ); - }, + $.fn.exclusive.Constructor = Lock; - destroy: function() { - autoLock.clearTimer(); - autoLock.lockId = 0; - } -}; -$.autoLock = autoLock; +}(window.jQuery); /* UI & form events @@ -341,14 +286,13 @@ $.refreshTicketView = function(interval) { clearInterval(refresh); $.pjax({url: document.location.href, container:'#pjax-container'}); }, interval); -} +}; var ticket_onload = function($) { if (0 === $('#ticketThread').length) return; - //Start watching the form for activity. - autoLock.Init(); + $(function(){$('.exclusive[data-lock-object-id]').exclusive();}); /*** Ticket Actions **/ //print options TODO: move to backend -- GitLab