[Rt-commit] rt branch, 4.6/inline-edit, created. rt-4.4.1-125-gbe5756d

Shawn Moore shawn at bestpractical.com
Wed Aug 31 16:27:10 EDT 2016


The branch, 4.6/inline-edit has been created
        at  be5756da6c1f93c5967a6b7e2832e1dc0d63a75b (commit)

- Log -----------------------------------------------------------------
commit 833904f5a6ff1bb811a3b9110c030c5aff10cf2c
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Aug 31 16:54:52 2016 +0000

    Add loading.gif spinner
    
    This animation is in the public domain
    Source: https://commons.wikimedia.org/wiki/File:Ajax-loader.gif

diff --git a/share/static/images/loading.gif b/share/static/images/loading.gif
new file mode 100644
index 0000000..3288d10
Binary files /dev/null and b/share/static/images/loading.gif differ

commit c9a3085a579065cc7ab67e6c1cb8a8344c0afceb
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Aug 25 15:53:08 2016 +0000

    Add a helper AJAX endpoint for generic ticket update
    
    This code cribbed from /Ticket/Display.html

diff --git a/share/html/Helpers/TicketUpdate b/share/html/Helpers/TicketUpdate
new file mode 100644
index 0000000..e265f8c
--- /dev/null
+++ b/share/html/Helpers/TicketUpdate
@@ -0,0 +1,80 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+% $r->content_type('application/json; charset=utf-8');
+<% JSON({
+    actions => \@Actions,
+}) |n %>
+% $m->abort;
+<%ARGS>
+$id
+</%ARGS>
+<%INIT>
+my @Actions;
+
+my $TicketObj = LoadTicket($id);
+
+$TicketObj->CurrentUser->PrincipalObj->HasRights( Object => $TicketObj );
+
+$m->callback(CallbackName => 'ProcessArguments',
+             Ticket => $TicketObj,
+             ARGSRef => \%ARGS,
+             Actions => \@Actions);
+
+push @Actions, ProcessUpdateMessage(
+    ARGSRef   => \%ARGS,
+    Actions   => \@Actions,
+    TicketObj => $TicketObj,
+);
+
+push @Actions, ProcessTicketWatchers(ARGSRef => \%ARGS, TicketObj => $TicketObj );
+push @Actions, ProcessTicketBasics(  ARGSRef => \%ARGS, TicketObj => $TicketObj );
+push @Actions, ProcessTicketLinks(   ARGSRef => \%ARGS, TicketObj => $TicketObj );
+push @Actions, ProcessTicketDates(   ARGSRef => \%ARGS, TicketObj => $TicketObj );
+push @Actions, ProcessObjectCustomFieldUpdates(ARGSRef => \%ARGS, TicketObj => $TicketObj );
+push @Actions, ProcessTicketReminders( ARGSRef => \%ARGS, TicketObj => $TicketObj );
+</%INIT>

commit 0054d9cdef2d23cc4ff09b462aec8268119124a1
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Aug 25 15:54:09 2016 +0000

    Add comment explaining why HasRights is called without checking return value
    
    This optimization was added to /Ticket/Display.html in 7d891dc8, but
    without the comment it may appear vestigial.

diff --git a/share/html/Helpers/TicketUpdate b/share/html/Helpers/TicketUpdate
index e265f8c..777c5c9 100644
--- a/share/html/Helpers/TicketUpdate
+++ b/share/html/Helpers/TicketUpdate
@@ -58,6 +58,7 @@ my @Actions;
 
 my $TicketObj = LoadTicket($id);
 
+# fill ACL cache
 $TicketObj->CurrentUser->PrincipalObj->HasRights( Object => $TicketObj );
 
 $m->callback(CallbackName => 'ProcessArguments',
diff --git a/share/html/Ticket/Display.html b/share/html/Ticket/Display.html
index 4c49857..1e821ae 100644
--- a/share/html/Ticket/Display.html
+++ b/share/html/Ticket/Display.html
@@ -156,6 +156,7 @@ if ($ARGS{'id'} eq 'new') {
 } else { 
     $TicketObj ||= LoadTicket($ARGS{'id'});
 
+    # fill ACL cache
     $TicketObj->CurrentUser->PrincipalObj->HasRights( Object => $TicketObj );
 
     my $SkipProcessing;

commit 60cd9ece5e2bd1aaa8152c80bdfac88165cfae52
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Aug 31 18:23:30 2016 +0000

    Add data- attributes to CollectionList table and tbody

diff --git a/share/html/Elements/CollectionAsTable/Row b/share/html/Elements/CollectionAsTable/Row
index c6f1319..2872d68 100644
--- a/share/html/Elements/CollectionAsTable/Row
+++ b/share/html/Elements/CollectionAsTable/Row
@@ -57,7 +57,7 @@ $Class     => 'RT__Ticket'
 $Classes => ''
 </%ARGS>
 <%init>
-$m->out( '<tbody class="list-item"' . ( $record->can('id') ? ' data-record-id="'.$record->id.'"' : '' ) . '>' );
+$m->out( '<tbody class="list-item" data-index="'.$i.'" ' . ( $record->can('id') ? ' data-record-id="'.$record->id.'"' : '' )  . ( $Warning ? ' data-warning=1' : ''). '>' );
 
 $m->out(  '<tr class="' . $Classes . ' '
         . ( $Warning ? 'warnline' : $i % 2 ? 'oddline' : 'evenline' ) . '" >'
diff --git a/share/html/Elements/CollectionList b/share/html/Elements/CollectionList
index fd8c6e6..07ee2bb 100644
--- a/share/html/Elements/CollectionList
+++ b/share/html/Elements/CollectionList
@@ -116,8 +116,12 @@ if ($Class =~ /::/) { # older passed in value
     $Class =~ s/:/_/g;
 }
 
-$m->out('<table cellspacing="0" class="' .
-            ($Collection->isa('RT::Tickets') ? 'ticket-list' : 'collection') . ' collection-as-table">');
+$m->out('<table cellspacing="0"');
+$m->out(' class="' .  ($Collection->isa('RT::Tickets') ? 'ticket-list' : 'collection') . ' collection-as-table"');
+$m->out(' data-display-format="' . $m->interp->apply_escapes($DisplayFormat, 'h') . '"');
+$m->out(' data-max-items="' . $maxitems . '"');
+$m->out(' data-class="' . $Collection->RecordClass . '"');
+$m->out('>');
 
 if ( $ShowHeader ) {
     $m->comp('/Elements/CollectionAsTable/Header',

commit f71b296b1d8906b7bb22fe1794ca27ed3f8bd863
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Aug 31 19:44:36 2016 +0000

    Add an AJAX helper for refreshing CollectionList rows
    
    Now that we're effectively exposing /Elements/CollectionAsTable/Row
    to the browser without having gone through /Elements/CollectionList, we
    need to escape some of its arguments when we render them into HTML.

diff --git a/share/html/Elements/CollectionAsTable/Row b/share/html/Elements/CollectionAsTable/Row
index 2872d68..db136a5 100644
--- a/share/html/Elements/CollectionAsTable/Row
+++ b/share/html/Elements/CollectionAsTable/Row
@@ -57,9 +57,9 @@ $Class     => 'RT__Ticket'
 $Classes => ''
 </%ARGS>
 <%init>
-$m->out( '<tbody class="list-item" data-index="'.$i.'" ' . ( $record->can('id') ? ' data-record-id="'.$record->id.'"' : '' )  . ( $Warning ? ' data-warning=1' : ''). '>' );
+$m->out( '<tbody class="list-item" data-index="'.$m->interp->apply_escapes($i, 'h').'" ' . ( $record->can('id') ? ' data-record-id="'.$record->id.'"' : '' )  . ( $Warning ? ' data-warning=1' : ''). '>' );
 
-$m->out(  '<tr class="' . $Classes . ' '
+$m->out(  '<tr class="' . $m->interp->apply_escapes($Classes, 'h') . ' '
         . ( $Warning ? 'warnline' : $i % 2 ? 'oddline' : 'evenline' ) . '" >'
         . "\n" );
 my $item;
diff --git a/share/html/Helpers/CollectionListRow b/share/html/Helpers/CollectionListRow
new file mode 100644
index 0000000..b54ddc0
--- /dev/null
+++ b/share/html/Helpers/CollectionListRow
@@ -0,0 +1,77 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%ARGS>
+$DisplayFormat => undef
+$ObjectClass   => undef
+$MaxItems      => undef
+
+$i             => undef
+$ObjectId      => undef
+$Warning       => undef
+</%ARGS>
+<%INIT>
+# Scrub the html of the format string to remove any potential nasties.
+$DisplayFormat = $m->comp('/Elements/ScrubHTML', Content => $DisplayFormat);
+my @Format = $m->comp('/Elements/CollectionAsTable/ParseFormat', Format => $DisplayFormat);
+
+$m->abort unless $ObjectClass eq 'RT::Ticket';
+
+my $record = $ObjectClass->new($session{CurrentUser});
+$record->Load($ObjectId);
+$m->abort unless $record->id;
+
+$m->comp('/Elements/CollectionAsTable/Row',
+    i         => $i,
+    Format    => \@Format,
+    record    => $record,
+    maxitems  => $MaxItems,
+    Class     => $record->ColumnMapClassName,
+    Warning   => $Warning,
+);
+$m->abort;
+</%INIT>

commit 3b866197880ba82b80c6191a575ee60405eac0ad
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Aug 31 19:48:45 2016 +0000

    Add refreshCollectionListRow JavaScript function

diff --git a/share/static/js/util.js b/share/static/js/util.js
index 26f404d..c951980 100644
--- a/share/static/js/util.js
+++ b/share/static/js/util.js
@@ -520,6 +520,30 @@ function escapeCssSelector(str) {
     return str.replace(/([^A-Za-z0-9_-])/g,'\\$1');
 }
 
+function refreshCollectionListRow(tbody, success, error) {
+    var table = tbody.closest('table');
+
+    var params = {
+        DisplayFormat : table.data('display-format'),
+        ObjectClass   : table.data('class'),
+        MaxItems      : table.data('max-items'),
+
+        i             : tbody.data('index'),
+        ObjectId      : tbody.data('record-id'),
+        Warning       : tbody.data('warning')
+    };
+
+    jQuery.ajax({
+        url    : RT.Config.WebHomePath + '/Helpers/CollectionListRow',
+        method : 'GET',
+        data   : params,
+        success: function (response) {
+            tbody.replaceWith(response);
+            if (success) { success(response) }
+        },
+        error: error,
+    });
+}
 
 jQuery(function() {
     jQuery(".user-accordion").each(function(){

commit 0b58387220f80efcf799235fbb77209e24357866
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Aug 25 15:51:40 2016 +0000

    Add "edit" entry to Ticket ColumnMap

diff --git a/share/html/Elements/RT__Ticket/ColumnMap b/share/html/Elements/RT__Ticket/ColumnMap
index c7303d9..e0c298e 100644
--- a/share/html/Elements/RT__Ticket/ColumnMap
+++ b/share/html/Elements/RT__Ticket/ColumnMap
@@ -95,27 +95,32 @@ $COLUMN_MAP = {
     Queue => {
         attribute => 'Queue',
         title     => 'Queue id', # loc
-        value     => sub { return $_[0]->Queue }
+        value     => sub { return $_[0]->Queue },
+        edit      => sub { return \($m->scomp('/Elements/SelectQueue', Default => $_[0]->Queue, Name => 'Queue', ShowNullOption => 0)) },
     },
     QueueName => {
         attribute => 'Queue',
         title     => 'Queue', # loc
-        value     => sub { return $_[0]->QueueObj->Name }
+        value     => sub { return $_[0]->QueueObj->Name },
+        edit      => sub { return \($m->scomp('/Elements/SelectQueue', Default => $_[0]->Queue, Name => 'Queue', ShowNullOption => 0)) },
     },
     OwnerName => {
         title     => 'Owner', # loc
         attribute => 'Owner',
-        value     => sub { return $_[0]->OwnerObj->Name }
+        value     => sub { return $_[0]->OwnerObj->Name },
+        edit      => sub { return \($m->scomp('/Elements/SelectOwner', TicketObj => $_[0], Name => 'Owner', Default => $_[0]->OwnerObj->Id, DefaultValue => 0)) },
     },
     Status => {
         title     => 'Status', # loc
         attribute => 'Status',
-        value     => sub { return loc($_[0]->Status) }
+        value     => sub { return loc($_[0]->Status) },
+        edit      => sub { return \($m->scomp("/Ticket/Elements/SelectStatus", TicketObj => $_[0], Name => 'Status' ) ) },
     },
     Subject => {
         title     => 'Subject', # loc
         attribute => 'Subject',
-        value     => sub { return $_[0]->Subject || "(" . loc('No subject') . ")" }
+        value     => sub { return $_[0]->Subject || "(" . loc('No subject') . ")" },
+        edit      => sub { return \('<input name="Subject" value="'.$m->interp->apply_escapes( $_[0]->Subject, 'h' ).'" />') },
     },
     ExtendedStatus => {
         title     => 'Status', # loc
@@ -148,24 +153,28 @@ $COLUMN_MAP = {
                 return loc( $Ticket->Status );
             }
 
-          }
+        },
+        edit      => sub { return \($m->scomp("/Ticket/Elements/SelectStatus", TicketObj => $_[0], Name => 'Status' ) ) },
     },
     Priority => {
         title     => 'Priority', # loc
         attribute => 'Priority',
-        value     => sub { return $_[0]->Priority }
+        value     => sub { return $_[0]->Priority },
+        edit      => sub { return \($m->scomp('/Elements/SelectPriority', Name => 'Priority', Default => $_[0]->Priority)) },
     },
     InitialPriority => {
         title     => 'InitialPriority', # loc
         attribute => 'InitialPriority',
         name      => 'Initial Priority',
-        value     => sub { return $_[0]->InitialPriority }
+        value     => sub { return $_[0]->InitialPriority },
+        edit      => sub { return \($m->scomp('/Elements/SelectPriority', Name => 'InitialPriority', Default => $_[0]->InitialPriority)) },
     },
     FinalPriority => {
         title     => 'FinalPriority', # loc
         attribute => 'FinalPriority',
         name      => 'Final Priority',
-        value     => sub { return $_[0]->FinalPriority }
+        value     => sub { return $_[0]->FinalPriority },
+        edit      => sub { return \($m->scomp('/Elements/SelectPriority', Name => 'FinalPriority', Default => $_[0]->FinalPriority)) },
     },
     EffectiveId => {
         title     => 'EffectiveId', # loc
@@ -180,32 +189,38 @@ $COLUMN_MAP = {
     TimeWorked => {
         attribute => 'TimeWorked',
         title     => 'Time Worked', # loc
-        value     => sub { return $_[0]->TimeWorkedAsString }
+        value     => sub { return $_[0]->TimeWorkedAsString },
+        edit      => sub { return \($m->scomp('/Elements/EditTimeValue', Name => 'TimeWorked', Default => $_[0]->TimeWorked)) },
     },
     TimeLeft => {
         attribute => 'TimeLeft',
         title     => 'Time Left', # loc
-        value     => sub { return $_[0]->TimeLeftAsString }
+        value     => sub { return $_[0]->TimeLeftAsString },
+        edit      => sub { return \($m->scomp('/Elements/EditTimeValue', Name => 'TimeLeft', Default => $_[0]->TimeLeft)) },
     },
     TimeEstimated => {
         attribute => 'TimeEstimated',
         title     => 'Time Estimated', # loc
-        value     => sub { return $_[0]->TimeEstimatedAsString }
+        value     => sub { return $_[0]->TimeEstimatedAsString },
+        edit      => sub { return \($m->scomp('/Elements/EditTimeValue', Name => 'TimeEstimated', Default => $_[0]->TimeEstimated)) },
     },
     StartsRelative => {
         title     => 'Starts', # loc
         attribute => 'Starts',
-        value     => sub { return $_[0]->StartsObj->AgeAsString }
+        value     => sub { return $_[0]->StartsObj->AgeAsString },
+        edit      => sub { return \($m->scomp('/Elements/SelectDate', menu_prefix => 'Starts', current => 0, Default => $_[0]->StartsObj->Unix ? $_[0]->StartsObj->ISO( Timezone => 'user' ) : '')) },
     },
     StartedRelative => {
         title     => 'Started', # loc
         attribute => 'Started',
-        value     => sub { return $_[0]->StartedObj->AgeAsString }
+        value     => sub { return $_[0]->StartedObj->AgeAsString },
+        edit      => sub { return \($m->scomp('/Elements/SelectDate', menu_prefix => 'Started', current => 0, Default => $_[0]->StartedObj->Unix ? $_[0]->StartedObj->ISO( Timezone => 'user' ) : '')) },
     },
     ToldRelative => {
         title     => 'Told', # loc
         attribute => 'Told',
-        value     => sub { return $_[0]->ToldObj->AgeAsString }
+        value     => sub { return $_[0]->ToldObj->AgeAsString },
+        edit      => sub { return \($m->scomp('/Elements/SelectDate', menu_prefix => 'Told', current => 0, Default => $_[0]->ToldObj->Unix ? $_[0]->ToldObj->ISO( Timezone => 'user' ) : '')) },
     },
     DueRelative => {
         title     => 'Due', # loc
@@ -218,7 +233,8 @@ $COLUMN_MAP = {
             } else {
                 return $date->AgeAsString;
             }
-        }
+        },
+        edit      => sub { return \($m->scomp('/Elements/SelectDate', menu_prefix => 'Due', current => 0, Default => $_[0]->DueObj->Unix ? $_[0]->DueObj->ISO( Timezone => 'user' ) : '')) },
     },
     ResolvedRelative => {
         title     => 'Resolved', # loc
@@ -228,17 +244,20 @@ $COLUMN_MAP = {
     Starts => {
         title     => 'Starts', # loc
         attribute => 'Starts',
-        value     => sub { return $_[0]->StartsObj->AsString }
+        value     => sub { return $_[0]->StartsObj->AsString },
+        edit      => sub { return \($m->scomp('/Elements/SelectDate', menu_prefix => 'Starts', current => 0, Default => $_[0]->StartsObj->Unix ? $_[0]->StartsObj->ISO( Timezone => 'user' ) : '')) },
     },
     Started => {
         title     => 'Started', # loc
         attribute => 'Started',
-        value     => sub { return $_[0]->StartedObj->AsString }
+        value     => sub { return $_[0]->StartedObj->AsString },
+        edit      => sub { return \($m->scomp('/Elements/SelectDate', menu_prefix => 'Started', current => 0, Default => $_[0]->StartedObj->Unix ? $_[0]->StartedObj->ISO( Timezone => 'user' ) : '')) },
     },
     Told => {
         title     => 'Told', # loc
         attribute => 'Told',
-        value     => sub { return $_[0]->ToldObj->AsString }
+        value     => sub { return $_[0]->ToldObj->AsString },
+        edit      => sub { return \($m->scomp('/Elements/SelectDate', menu_prefix => 'Told', current => 0, Default => $_[0]->ToldObj->Unix ? $_[0]->ToldObj->ISO( Timezone => 'user' ) : '')) },
     },
     Due => {
         title     => 'Due', # loc
@@ -251,7 +270,8 @@ $COLUMN_MAP = {
             } else {
                 return $date->AsString;
             }
-        }
+        },
+        edit      => sub { return \($m->scomp('/Elements/SelectDate', menu_prefix => 'Due', current => 0, Default => $_[0]->DueObj->Unix ? $_[0]->DueObj->ISO( Timezone => 'user' ) : '')) },
     },
     Resolved => {
         title     => 'Resolved', # loc

commit e19b7e18487ba130c37e7593d8397f2589a2a7dc
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Aug 25 15:52:16 2016 +0000

    Add form and edit widget from ColumnMap to search results

diff --git a/share/html/Elements/CollectionAsTable/Row b/share/html/Elements/CollectionAsTable/Row
index db136a5..79b3710 100644
--- a/share/html/Elements/CollectionAsTable/Row
+++ b/share/html/Elements/CollectionAsTable/Row
@@ -81,7 +81,7 @@ foreach my $column (@Format) {
     my $class = $column->{class} ? $m->interp->apply_escapes($column->{class}, 'h') : 'collection-as-table';
 
     my %attrs;
-    foreach my $attr (qw(style align)) {
+    foreach my $attr (qw(style align edit)) {
         if ( defined $column->{ $attr } ) {
             $attrs{ $attr } = $column->{ $attr };
             next;
@@ -109,6 +109,10 @@ foreach my $column (@Format) {
     $attrs{colspan} = $column->{span};
     $item += ($attrs{'colspan'} || 1);
 
+    if ($attrs{edit}) {
+        $class .= ' editable';
+    }
+
     my @out;
     foreach my $subcol ( @{ $column->{output} } ) {
         my ($col) = ($subcol =~ /^__(.*?)__$/);
@@ -141,6 +145,7 @@ foreach my $column (@Format) {
         Align        => \$attrs{align},
         Style        => \$attrs{style},
         Colspan      => \$attrs{colspan},
+        Edit         => \$attrs{edit},
         Out          => \@out,
     );
 
@@ -150,7 +155,18 @@ foreach my $column (@Format) {
         foreach grep $attrs{$_}, qw(align style colspan);
 
     $m->out('>');
+
+    if ($attrs{edit}) {
+        $m->out('<form method="POST" action="'.RT->Config->Get('WebPath').'/Helpers/TicketUpdate?id='.$record->id.'" class="editor">' . $attrs{edit} . '</form>');
+        $m->out('<div class="value">');
+    }
+
     $m->out(@out) if @out;
+
+    if ($attrs{edit}) {
+        $m->out('</div>');
+    }
+
     $m->out( '</td>' . "\n" );
 }
 $m->out('</tr>');
diff --git a/share/static/css/base/collection.css b/share/static/css/base/collection.css
index a061999..2b7c173 100644
--- a/share/static/css/base/collection.css
+++ b/share/static/css/base/collection.css
@@ -24,3 +24,11 @@ table.collection td:first-child, table.collection th:first-child {
 .results-count::before {
     content: '\a0\a0\a0\a0';
 }
+
+td.editable > form.editor {
+    display: none;
+}
+
+td.editable > .value {
+    padding-top: 0;
+}

commit 25fa95ec3a17033df4ae797642753c8bbfaea581
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Aug 25 16:02:07 2016 +0000

    Click a cell to show editor and hide readonly display

diff --git a/share/static/css/base/collection.css b/share/static/css/base/collection.css
index 2b7c173..222c509 100644
--- a/share/static/css/base/collection.css
+++ b/share/static/css/base/collection.css
@@ -32,3 +32,7 @@ td.editable > form.editor {
 td.editable > .value {
     padding-top: 0;
 }
+
+td.editing > .value {
+    display: none;
+}
diff --git a/share/static/js/util.js b/share/static/js/util.js
index c951980..55a3ea1 100644
--- a/share/static/js/util.js
+++ b/share/static/js/util.js
@@ -573,6 +573,24 @@ jQuery(function() {
     });
 });
 
+/* inline edit */
+jQuery(function () {
+    // stop propagation when we click a hyperlink (e.g. ticket subject) so that
+    // the td.editable onclick handler doesn't also fire
+    jQuery(document).on('click', 'td.editable a', function (e) {
+        e.stopPropagation();
+    });
+
+    jQuery(document).on('click', 'td.editable', function (e) {
+        var cell = jQuery(this);
+        var value = cell.find('.value');
+        var editor = cell.find('.editor');
+
+        cell.removeClass('editable').addClass('editing');
+        editor.find(':input:visible:enabled:first').focus();
+    });
+});
+
 // focus jquery object in window, only moving the screen when necessary
 function scrollToJQueryObject(obj) {
     if (!obj.length) return;

commit cc207187ab5fd25b6a3b0bf4a3c13365a486267e
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Aug 25 16:16:37 2016 +0000

    After each table is rendered, fix its column widths
    
    Inline edit changes the size of search result columns due to the content of
    cells changing from readonly labels to (usually wider) form fields. Letting
    this occur is sloppy and surprising to users, as suddenly all the text jumps
    around. We address this by locking the table's layout before the first
    inline edit occurs.
    
    While we could have done this in CSS using a simplistic approach like:
    
        table.collection-as-table {
            table-layout: fixed;
        }
    
    That would severely degrade the UI, as each column would then get the same
    width regardless of its content. By implementing this in JavaScript, we can
    inspect the table after it has fully rendered, locking each column's width
    to its natural, calculated size.
    
    Tables without <col> tags do not have their layout fixed.

diff --git a/share/html/Elements/CollectionList b/share/html/Elements/CollectionList
index 07ee2bb..890e9e3 100644
--- a/share/html/Elements/CollectionList
+++ b/share/html/Elements/CollectionList
@@ -123,6 +123,12 @@ $m->out(' data-max-items="' . $maxitems . '"');
 $m->out(' data-class="' . $Collection->RecordClass . '"');
 $m->out('>');
 
+if ( $ShowCols ) {
+    $m->out('<colgroup>');
+    $m->out('<col>') for 1 .. $maxitems;
+    $m->out('</colgroup>');
+}
+
 if ( $ShowHeader ) {
     $m->comp('/Elements/CollectionAsTable/Header',
         %ARGS,
@@ -211,6 +217,7 @@ $AllowSorting   => 0  # Make headers in table links that will resort results
 $PreferOrderBy  => 0  # Prefer the passed-in @OrderBy to the collection default
 $ShowNavigation => 1
 $ShowHeader     => 1
+$ShowCols       => 1
 $ShowEmpty      => 0
 $Query => 0
 $HasResults     => undef
diff --git a/share/static/js/util.js b/share/static/js/util.js
index 55a3ea1..6e1d1d0 100644
--- a/share/static/js/util.js
+++ b/share/static/js/util.js
@@ -589,6 +589,20 @@ jQuery(function () {
         cell.removeClass('editable').addClass('editing');
         editor.find(':input:visible:enabled:first').focus();
     });
+
+    jQuery('table.collection-as-table').each(function () {
+        var table = jQuery(this);
+        var cols = table.find('colgroup col');
+        if (cols.length == 0) {
+            return;
+        }
+
+        cols.each(function () {
+            var col = jQuery(this);
+            col.attr('width', col.width());
+        });
+        table.css('table-layout', 'fixed');
+    });
 });
 
 // focus jquery object in window, only moving the screen when necessary

commit 64267e112812b63ba681905206d260c53acdc647
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Aug 25 16:32:38 2016 +0000

    Post inline edit to the server after intentional UI events
    
    Clicking (blurring) out of the form, selecting an option from a dropdown,
    hitting enter, etc. all submit the AJAX request.
    
    The results aren't displayed and the table row doesn't reload yet, but the
    update does take.

diff --git a/share/static/js/util.js b/share/static/js/util.js
index 6e1d1d0..82ea0c6 100644
--- a/share/static/js/util.js
+++ b/share/static/js/util.js
@@ -590,6 +590,29 @@ jQuery(function () {
         editor.find(':input:visible:enabled:first').focus();
     });
 
+    jQuery(document).on('focusout', 'td.editing form', function () {
+        var editor = jQuery(this);
+        var cell = editor.closest('td');
+        var value = cell.find('.value');
+
+        cell.removeClass('editing').addClass('editable');
+
+        jQuery.ajax({
+            url     : editor.attr('action'),
+            method  : 'POST',
+            data    : editor.serialize()
+        });
+    });
+
+    jQuery(document).on('submit', 'td.editing form', function (e) {
+        e.preventDefault();
+        jQuery(this).find(':input:focus').blur();
+    });
+
+    jQuery(document).on('change', 'td.editing form select', function () {
+        jQuery(this).closest('form').trigger('submit');
+    });
+
     jQuery('table.collection-as-table').each(function () {
         var table = jQuery(this);
         var cols = table.find('colgroup col');

commit a99d17dfb1746d1c245413d56461c7d2457a2c9b
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Aug 31 20:03:16 2016 +0000

    Disable form fields as soon as form is submitted

diff --git a/share/static/js/util.js b/share/static/js/util.js
index 82ea0c6..72c946e 100644
--- a/share/static/js/util.js
+++ b/share/static/js/util.js
@@ -595,12 +595,15 @@ jQuery(function () {
         var cell = editor.closest('td');
         var value = cell.find('.value');
 
+        var params = editor.serialize();
+
         cell.removeClass('editing').addClass('editable');
+        editor.find(':input').attr('disabled', 'disabled');
 
         jQuery.ajax({
             url     : editor.attr('action'),
             method  : 'POST',
-            data    : editor.serialize()
+            data    : params
         });
     });
 

commit 793f25682f85631109c996159ecd2624e411b1ee
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Aug 31 17:12:47 2016 +0000

    Add loading spinner while the form is being submitted

diff --git a/share/html/Elements/CollectionAsTable/Row b/share/html/Elements/CollectionAsTable/Row
index 79b3710..c11c8c0 100644
--- a/share/html/Elements/CollectionAsTable/Row
+++ b/share/html/Elements/CollectionAsTable/Row
@@ -157,7 +157,7 @@ foreach my $column (@Format) {
     $m->out('>');
 
     if ($attrs{edit}) {
-        $m->out('<form method="POST" action="'.RT->Config->Get('WebPath').'/Helpers/TicketUpdate?id='.$record->id.'" class="editor">' . $attrs{edit} . '</form>');
+        $m->out('<form method="POST" action="'.RT->Config->Get('WebPath').'/Helpers/TicketUpdate?id='.$record->id.'" class="editor">' . $attrs{edit} . '<img class="loading" src="'.RT->Config->Get('WebPath').'/static/images/loading.gif" alt="'.loc('Loading').'"/></form>');
         $m->out('<div class="value">');
     }
 
diff --git a/share/static/css/base/collection.css b/share/static/css/base/collection.css
index 222c509..1596e51 100644
--- a/share/static/css/base/collection.css
+++ b/share/static/css/base/collection.css
@@ -29,6 +29,11 @@ td.editable > form.editor {
     display: none;
 }
 
+td.editable > form.editor > img.loading,
+td.editing > form.editor > img.loading {
+    display: none;
+}
+
 td.editable > .value {
     padding-top: 0;
 }
@@ -36,3 +41,9 @@ td.editable > .value {
 td.editing > .value {
     display: none;
 }
+
+td.editing.loading > form.editor > img.loading {
+    display: inline-block;
+    height: 1em;
+    width: 1em;
+}
diff --git a/share/static/js/util.js b/share/static/js/util.js
index 72c946e..30d48f8 100644
--- a/share/static/js/util.js
+++ b/share/static/js/util.js
@@ -599,6 +599,7 @@ jQuery(function () {
 
         cell.removeClass('editing').addClass('editable');
         editor.find(':input').attr('disabled', 'disabled');
+        cell.addClass('loading');
 
         jQuery.ajax({
             url     : editor.attr('action'),

commit 81d815e29c5bb23e326549a01090e1e3cced7681
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Aug 25 16:58:02 2016 +0000

    Add jGrowl 1.4.5

diff --git a/devel/third-party/README b/devel/third-party/README
index 8d151d3..5e745ad 100644
--- a/devel/third-party/README
+++ b/devel/third-party/README
@@ -29,6 +29,11 @@ Description: DOM manipulation
 Origin: http://code.jquery.com/jquery-1.11.3.js
 License: MIT
 
+* jquery-jgrowl-1.4.5/
+Description: javascript notifications
+Origin: https://github.com/stanlemon/jGrowl/tree/1.4.5
+License: MIT
+
 * jquery-modal-0.5.2.js
 Description: modal popup dialogs
 Origin: https://github.com/kylefox/jquery-modal
diff --git a/devel/third-party/jquery-jgrowl-1.4.5/jquery.jgrowl.css b/devel/third-party/jquery-jgrowl-1.4.5/jquery.jgrowl.css
new file mode 100644
index 0000000..4a00b60
--- /dev/null
+++ b/devel/third-party/jquery-jgrowl-1.4.5/jquery.jgrowl.css
@@ -0,0 +1,100 @@
+.jGrowl {
+  z-index: 9999;
+  color: #ffffff;
+  font-size: 12px;
+  font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+  position: fixed;
+}
+.jGrowl.top-left {
+  left: 0px;
+  top: 0px;
+}
+.jGrowl.top-right {
+  right: 0px;
+  top: 0px;
+}
+.jGrowl.bottom-left {
+  left: 0px;
+  bottom: 0px;
+}
+.jGrowl.bottom-right {
+  right: 0px;
+  bottom: 0px;
+}
+.jGrowl.center {
+  top: 0px;
+  width: 50%;
+  left: 25%;
+}
+.jGrowl.center .jGrowl-notification,
+.jGrowl.center .jGrowl-closer {
+  margin-left: auto;
+  margin-right: auto;
+}
+.jGrowl-notification {
+  background-color: #000000;
+  opacity: 0.9;
+  filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=(0.9*100));
+  -ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=(0.9*100));
+  zoom: 1;
+  width: 250px;
+  padding: 10px;
+  margin: 10px;
+  text-align: left;
+  display: none;
+  border-radius: 5px;
+  min-height: 40px;
+}
+.jGrowl-notification .ui-state-highlight,
+.jGrowl-notification .ui-widget-content .ui-state-highlight,
+.jGrowl-notification .ui-widget-header .ui-state-highlight {
+  border: 1px solid #000;
+  background: #000;
+  color: #fff;
+}
+.jGrowl-notification .jGrowl-header {
+  font-weight: bold;
+  font-size: .85em;
+}
+.jGrowl-notification .jGrowl-close {
+  background-color: transparent;
+  color: inherit;
+  border: none;
+  z-index: 99;
+  float: right;
+  font-weight: bold;
+  font-size: 1em;
+  cursor: pointer;
+}
+.jGrowl-closer {
+  background-color: #000000;
+  opacity: 0.9;
+  filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=(0.9*100));
+  -ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=(0.9*100));
+  zoom: 1;
+  width: 250px;
+  padding: 10px;
+  margin: 10px;
+  text-align: left;
+  display: none;
+  border-radius: 5px;
+  padding-top: 4px;
+  padding-bottom: 4px;
+  cursor: pointer;
+  font-size: .9em;
+  font-weight: bold;
+  text-align: center;
+}
+.jGrowl-closer .ui-state-highlight,
+.jGrowl-closer .ui-widget-content .ui-state-highlight,
+.jGrowl-closer .ui-widget-header .ui-state-highlight {
+  border: 1px solid #000;
+  background: #000;
+  color: #fff;
+}
+/** Hide jGrowl when printing **/
+ at media print {
+  .jGrowl {
+    display: none;
+  }
+}
diff --git a/devel/third-party/jquery-jgrowl-1.4.5/jquery.jgrowl.js b/devel/third-party/jquery-jgrowl-1.4.5/jquery.jgrowl.js
new file mode 100644
index 0000000..d5444d4
--- /dev/null
+++ b/devel/third-party/jquery-jgrowl-1.4.5/jquery.jgrowl.js
@@ -0,0 +1,399 @@
+/**
+ * jGrowl 1.4.5
+ *
+ * Dual licensed under the MIT (http://www.opensource.org/licenses/mit-license.php)
+ * and GPL (http://www.opensource.org/licenses/gpl-license.php) licenses.
+ *
+ * Written by Stan Lemon <stosh1985 at gmail.com>
+ * Last updated: 2015.02.01
+ *
+ * jGrowl is a jQuery plugin implementing unobtrusive userland notifications.  These
+ * notifications function similarly to the Growl Framework available for
+ * Mac OS X (http://growl.info).
+ *
+ * To Do:
+ * - Move library settings to containers and allow them to be changed per container
+ *
+ * Changes in 1.4.5
+ * - Fixed arguement list for click callback, thanks @timotheeg
+ *
+ * Changes in 1.4.4
+ * - Revert word-break changes, thanks @curtisgibby
+ *
+ * Changes in 1.4.3
+ * - Fixed opactiy in LESS for older version of IE
+ *
+ * Changes in 1.4.2
+ * - Added word-break to less/css
+ *
+ * Changes in 1.4.1
+ * - Added appendTo option
+ * - jQuery compatibility updates
+ * - Add check for closing a notification before it opens
+ *
+ * Changes in 1.4.0
+ * - Removed IE6 support
+ * - Added LESS support
+ *
+ * Changes in 1.3.0
+ * - Added non-vendor border-radius to stylesheet
+ * - Added grunt for generating minified js and css
+ * - Added npm package info
+ * - Added bower package info
+ * - Updates for jshint
+ *
+ * Changes in 1.2.13
+ * - Fixed clearing interval when the container shuts down
+ *
+ * Changes in 1.2.12
+ * - Added compressed versions using UglifyJS and Sqwish
+ * - Improved README with configuration options explanation
+ * - Added a source map
+ *
+ * Changes in 1.2.11
+ * - Fix artifacts left behind by the shutdown method and text-cleanup
+ *
+ * Changes in 1.2.10
+ * - Fix beforeClose to be called in click event
+ *
+ * Changes in 1.2.9
+ * - Fixed BC break in jQuery 2.0 beta
+ *
+ * Changes in 1.2.8
+ * - Fixes for jQuery 1.9 and the MSIE6 check, note that with jQuery 2.0 support
+ *   jGrowl intends to drop support for IE6 altogether
+ *
+ * Changes in 1.2.6
+ * - Fixed js error when a notification is opening and closing at the same time
+ *
+ * Changes in 1.2.5
+ * - Changed wrapper jGrowl's options usage to "o" instead of $.jGrowl.defaults
+ * - Added themeState option to control 'highlight' or 'error' for jQuery UI
+ * - Ammended some CSS to provide default positioning for nested usage.
+ * - Changed some CSS to be prefixed with jGrowl- to prevent namespacing issues
+ * - Added two new options - openDuration and closeDuration to allow
+ *   better control of notification open and close speeds, respectively
+ *   Patch contributed by Jesse Vincet.
+ * - Added afterOpen callback.  Patch contributed by Russel Branca.
+ *
+ * Changes in 1.2.4
+ * - Fixed IE bug with the close-all button
+ * - Fixed IE bug with the filter CSS attribute (special thanks to gotwic)
+ * - Update IE opacity CSS
+ * - Changed font sizes to use "em", and only set the base style
+ *
+ * Changes in 1.2.3
+ * - The callbacks no longer use the container as context, instead they use the actual notification
+ * - The callbacks now receive the container as a parameter after the options parameter
+ * - beforeOpen and beforeClose now check the return value, if it's false - the notification does
+ *   not continue.  The open callback will also halt execution if it returns false.
+ * - Fixed bug where containers would get confused
+ * - Expanded the pause functionality to pause an entire container.
+ *
+ * Changes in 1.2.2
+ * - Notification can now be theme rolled for jQuery UI, special thanks to Jeff Chan!
+ *
+ * Changes in 1.2.1
+ * - Fixed instance where the interval would fire the close method multiple times.
+ * - Added CSS to hide from print media
+ * - Fixed issue with closer button when div { position: relative } is set
+ * - Fixed leaking issue with multiple containers.  Special thanks to Matthew Hanlon!
+ *
+ * Changes in 1.2.0
+ * - Added message pooling to limit the number of messages appearing at a given time.
+ * - Closing a notification is now bound to the notification object and triggered by the close button.
+ *
+ * Changes in 1.1.2
+ * - Added iPhone styled example
+ * - Fixed possible IE7 bug when determining if the ie6 class shoudl be applied.
+ * - Added template for the close button, so that it's content could be customized.
+ *
+ * Changes in 1.1.1
+ * - Fixed CSS styling bug for ie6 caused by a mispelling
+ * - Changes height restriction on default notifications to min-height
+ * - Added skinned examples using a variety of images
+ * - Added the ability to customize the content of the [close all] box
+ * - Added jTweet, an example of using jGrowl + Twitter
+ *
+ * Changes in 1.1.0
+ * - Multiple container and instances.
+ * - Standard $.jGrowl() now wraps $.fn.jGrowl() by first establishing a generic jGrowl container.
+ * - Instance methods of a jGrowl container can be called by $.fn.jGrowl(methodName)
+ * - Added glue preferenced, which allows notifications to be inserted before or after nodes in the container
+ * - Added new log callback which is called before anything is done for the notification
+ * - Corner's attribute are now applied on an individual notification basis.
+ *
+ * Changes in 1.0.4
+ * - Various CSS fixes so that jGrowl renders correctly in IE6.
+ *
+ * Changes in 1.0.3
+ * - Fixed bug with options persisting across notifications
+ * - Fixed theme application bug
+ * - Simplified some selectors and manipulations.
+ * - Added beforeOpen and beforeClose callbacks
+ * - Reorganized some lines of code to be more readable
+ * - Removed unnecessary this.defaults context
+ * - If corners plugin is present, it's now customizable.
+ * - Customizable open animation.
+ * - Customizable close animation.
+ * - Customizable animation easing.
+ * - Added customizable positioning (top-left, top-right, bottom-left, bottom-right, center)
+ *
+ * Changes in 1.0.2
+ * - All CSS styling is now external.
+ * - Added a theme parameter which specifies a secondary class for styling, such
+ *   that notifications can be customized in appearance on a per message basis.
+ * - Notification life span is now customizable on a per message basis.
+ * - Added the ability to disable the global closer, enabled by default.
+ * - Added callbacks for when a notification is opened or closed.
+ * - Added callback for the global closer.
+ * - Customizable animation speed.
+ * - jGrowl now set itself up and tears itself down.
+ *
+ * Changes in 1.0.1:
+ * - Removed dependency on metadata plugin in favor of .data()
+ * - Namespaced all events
+ */
+(function($) {
+	/** jGrowl Wrapper - Establish a base jGrowl Container for compatibility with older releases. **/
+	$.jGrowl = function( m , o ) {
+		// To maintain compatibility with older version that only supported one instance we'll create the base container.
+		if ( $('#jGrowl').length === 0 )
+			$('<div id="jGrowl"></div>').addClass( (o && o.position) ? o.position : $.jGrowl.defaults.position ).appendTo( (o && o.appendTo) ? o.appendTo : $.jGrowl.defaults.appendTo );
+
+		// Create a notification on the container.
+		$('#jGrowl').jGrowl(m,o);
+	};
+
+
+	/** Raise jGrowl Notification on a jGrowl Container **/
+	$.fn.jGrowl = function( m , o ) {
+		// Short hand for passing in just an object to this method
+		if ( o === undefined && $.isPlainObject(m) ) {
+			o = m;
+			m = o.message;
+		}
+
+		if ( $.isFunction(this.each) ) {
+			var args = arguments;
+
+			return this.each(function() {
+				/** Create a jGrowl Instance on the Container if it does not exist **/
+				if ( $(this).data('jGrowl.instance') === undefined ) {
+					$(this).data('jGrowl.instance', $.extend( new $.fn.jGrowl(), { notifications: [], element: null, interval: null } ));
+					$(this).data('jGrowl.instance').startup( this );
+				}
+
+				/** Optionally call jGrowl instance methods, or just raise a normal notification **/
+				if ( $.isFunction($(this).data('jGrowl.instance')[m]) ) {
+					$(this).data('jGrowl.instance')[m].apply( $(this).data('jGrowl.instance') , $.makeArray(args).slice(1) );
+				} else {
+					$(this).data('jGrowl.instance').create( m , o );
+				}
+			});
+		}
+	};
+
+	$.extend( $.fn.jGrowl.prototype , {
+
+		/** Default JGrowl Settings **/
+		defaults: {
+			pool:				0,
+			header:				'',
+			group:				'',
+			sticky:				false,
+			position:			'top-right',
+			appendTo:			'body',
+			glue:				'after',
+			theme:				'default',
+			themeState:			'highlight',
+			corners:			'10px',
+			check:				250,
+			life:				3000,
+			closeDuration:		'normal',
+			openDuration:		'normal',
+			easing:				'swing',
+			closer:				true,
+			closeTemplate:		'×',
+			closerTemplate:		'<div>[ close all ]</div>',
+			log:				function() {},
+			beforeOpen:			function() {},
+			afterOpen:			function() {},
+			open:				function() {},
+			beforeClose:		function() {},
+			close:				function() {},
+			click:				function() {},
+			animateOpen:		{
+				opacity:		'show'
+			},
+			animateClose:		{
+				opacity:		'hide'
+			}
+		},
+
+		notifications: [],
+
+		/** jGrowl Container Node **/
+		element:				null,
+
+		/** Interval Function **/
+		interval:				null,
+
+		/** Create a Notification **/
+		create: function( message , options ) {
+			var o = $.extend({}, this.defaults, options);
+
+			/* To keep backward compatibility with 1.24 and earlier, honor 'speed' if the user has set it */
+			if (typeof o.speed !== 'undefined') {
+				o.openDuration = o.speed;
+				o.closeDuration = o.speed;
+			}
+
+			this.notifications.push({ message: message , options: o });
+
+			o.log.apply( this.element , [this.element,message,o] );
+		},
+
+		render: function( n ) {
+			var self = this;
+			var message = n.message;
+			var o = n.options;
+
+			// Support for jQuery theme-states, if this is not used it displays a widget header
+			o.themeState = (o.themeState === '') ? '' : 'ui-state-' + o.themeState;
+
+			var notification = $('<div/>')
+				.addClass('jGrowl-notification alert ' + o.themeState + ' ui-corner-all' + ((o.group !== undefined && o.group !== '') ? ' ' + o.group : ''))
+				.append($('<button/>').addClass('jGrowl-close').html(o.closeTemplate))
+				.append($('<div/>').addClass('jGrowl-header').html(o.header))
+				.append($('<div/>').addClass('jGrowl-message').html(message))
+				.data("jGrowl", o).addClass(o.theme).children('.jGrowl-close').bind("click.jGrowl", function() {
+					$(this).parent().trigger('jGrowl.beforeClose');
+					return false;
+				})
+				.parent();
+
+
+			/** Notification Actions **/
+			$(notification).bind("mouseover.jGrowl", function() {
+				$('.jGrowl-notification', self.element).data("jGrowl.pause", true);
+			}).bind("mouseout.jGrowl", function() {
+				$('.jGrowl-notification', self.element).data("jGrowl.pause", false);
+			}).bind('jGrowl.beforeOpen', function() {
+				if ( o.beforeOpen.apply( notification , [notification,message,o,self.element] ) !== false ) {
+					$(this).trigger('jGrowl.open');
+				}
+			}).bind('jGrowl.open', function() {
+				if ( o.open.apply( notification , [notification,message,o,self.element] ) !== false ) {
+					if ( o.glue == 'after' ) {
+						$('.jGrowl-notification:last', self.element).after(notification);
+					} else {
+						$('.jGrowl-notification:first', self.element).before(notification);
+					}
+
+					$(this).animate(o.animateOpen, o.openDuration, o.easing, function() {
+						// Fixes some anti-aliasing issues with IE filters.
+						if ($.support.opacity === false)
+							this.style.removeAttribute('filter');
+
+						if ( $(this).data("jGrowl") !== null && typeof $(this).data("jGrowl") !== 'undefined') // Happens when a notification is closing before it's open.
+							$(this).data("jGrowl").created = new Date();
+
+						$(this).trigger('jGrowl.afterOpen');
+					});
+				}
+			}).bind('jGrowl.afterOpen', function() {
+				o.afterOpen.apply( notification , [notification,message,o,self.element] );
+			}).bind('click', function() {
+				o.click.apply( notification, [notification,message,o,self.element] );
+			}).bind('jGrowl.beforeClose', function() {
+				if ( o.beforeClose.apply( notification , [notification,message,o,self.element] ) !== false )
+					$(this).trigger('jGrowl.close');
+			}).bind('jGrowl.close', function() {
+				// Pause the notification, lest during the course of animation another close event gets called.
+				$(this).data('jGrowl.pause', true);
+				$(this).animate(o.animateClose, o.closeDuration, o.easing, function() {
+					if ( $.isFunction(o.close) ) {
+						if ( o.close.apply( notification , [notification,message,o,self.element] ) !== false )
+							$(this).remove();
+					} else {
+						$(this).remove();
+					}
+				});
+			}).trigger('jGrowl.beforeOpen');
+
+			/** Optional Corners Plugin **/
+			if ( o.corners !== '' && $.fn.corner !== undefined ) $(notification).corner( o.corners );
+
+			/** Add a Global Closer if more than one notification exists **/
+			if ($('.jGrowl-notification:parent', self.element).length > 1 &&
+				$('.jGrowl-closer', self.element).length === 0 && this.defaults.closer !== false ) {
+				$(this.defaults.closerTemplate).addClass('jGrowl-closer ' + this.defaults.themeState + ' ui-corner-all').addClass(this.defaults.theme)
+					.appendTo(self.element).animate(this.defaults.animateOpen, this.defaults.speed, this.defaults.easing)
+					.bind("click.jGrowl", function() {
+						$(this).siblings().trigger("jGrowl.beforeClose");
+
+						if ( $.isFunction( self.defaults.closer ) ) {
+							self.defaults.closer.apply( $(this).parent()[0] , [$(this).parent()[0]] );
+						}
+					});
+			}
+		},
+
+		/** Update the jGrowl Container, removing old jGrowl notifications **/
+		update: function() {
+			$(this.element).find('.jGrowl-notification:parent').each( function() {
+				if ($(this).data("jGrowl") !== undefined && $(this).data("jGrowl").created !== undefined &&
+					($(this).data("jGrowl").created.getTime() + parseInt($(this).data("jGrowl").life, 10))  < (new Date()).getTime() &&
+					$(this).data("jGrowl").sticky !== true &&
+					($(this).data("jGrowl.pause") === undefined || $(this).data("jGrowl.pause") !== true) ) {
+
+					// Pause the notification, lest during the course of animation another close event gets called.
+					$(this).trigger('jGrowl.beforeClose');
+				}
+			});
+
+			if (this.notifications.length > 0 &&
+				(this.defaults.pool === 0 || $(this.element).find('.jGrowl-notification:parent').length < this.defaults.pool) )
+				this.render( this.notifications.shift() );
+
+			if ($(this.element).find('.jGrowl-notification:parent').length < 2 ) {
+				$(this.element).find('.jGrowl-closer').animate(this.defaults.animateClose, this.defaults.speed, this.defaults.easing, function() {
+					$(this).remove();
+				});
+			}
+		},
+
+		/** Setup the jGrowl Notification Container **/
+		startup: function(e) {
+			this.element = $(e).addClass('jGrowl').append('<div class="jGrowl-notification"></div>');
+			this.interval = setInterval( function() {
+				// some error in chage ^^
+				var instance = $(e).data('jGrowl.instance');
+				if (undefined !== instance) {
+					instance.update();
+				}
+			}, parseInt(this.defaults.check, 10));
+		},
+
+		/** Shutdown jGrowl, removing it and clearing the interval **/
+		shutdown: function() {
+			$(this.element).removeClass('jGrowl')
+				.find('.jGrowl-notification').trigger('jGrowl.close')
+				.parent().empty()
+			;
+
+			clearInterval(this.interval);
+		},
+
+		close: function() {
+			$(this.element).find('.jGrowl-notification').each(function(){
+				$(this).trigger('jGrowl.beforeClose');
+			});
+		}
+	});
+
+	/** Reference the Defaults Object for compatibility with older versions of jGrowl **/
+	$.jGrowl.defaults = $.fn.jGrowl.prototype.defaults;
+
+})(jQuery);
diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index 935c62a..402b154 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -134,6 +134,7 @@ sub JSFiles {
       assets.js
       /static/RichText/ckeditor.js
       dropzone.min.js
+      jquery.jgrowl.min.js
       }, RT->Config->Get('JSFiles');
 }
 
diff --git a/share/static/css/base/jquery.jgrowl.min.css b/share/static/css/base/jquery.jgrowl.min.css
new file mode 100644
index 0000000..a12f907
--- /dev/null
+++ b/share/static/css/base/jquery.jgrowl.min.css
@@ -0,0 +1 @@
+.jGrowl{z-index:9999;color:#fff;font-size:12px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;position:fixed}.jGrowl.top-left{left:0;top:0}.jGrowl.top-right{right:0;top:0}.jGrowl.bottom-left{left:0;bottom:0}.jGrowl.bottom-right{right:0;bottom:0}.jGrowl.center{top:0;width:50%;left:25%}.jGrowl.center .jGrowl-closer,.jGrowl.center .jGrowl-notification{margin-left:auto;margin-right:auto}.jGrowl-notification{background-color:#000;opacity:.9;filter:progid:DXImageTransform.Microsoft.Alpha(Opacity=(0.9*100));-ms-filter:progid:DXImageTransform.Microsoft.Alpha(Opacity=(0.9*100));zoom:1;width:250px;padding:10px;margin:10px;text-align:left;display:none;border-radius:5px;min-height:40px}.jGrowl-notification .ui-state-highlight,.jGrowl-notification .ui-widget-content .ui-state-highlight,.jGrowl-notification .ui-widget-header .ui-state-highlight{border:1px solid #000;background:#000;color:#fff}.jGrowl-notification .jGrowl-header{font-weight:700;font-size:.85em}.jGrowl-notification .jGrowl
 -close{background-color:transparent;color:inherit;border:none;z-index:99;float:right;font-weight:700;font-size:1em;cursor:pointer}.jGrowl-closer{background-color:#000;opacity:.9;filter:progid:DXImageTransform.Microsoft.Alpha(Opacity=(0.9*100));-ms-filter:progid:DXImageTransform.Microsoft.Alpha(Opacity=(0.9*100));zoom:1;width:250px;padding:10px;margin:10px;display:none;border-radius:5px;padding-top:4px;padding-bottom:4px;cursor:pointer;font-size:.9em;font-weight:700;text-align:center}.jGrowl-closer .ui-state-highlight,.jGrowl-closer .ui-widget-content .ui-state-highlight,.jGrowl-closer .ui-widget-header .ui-state-highlight{border:1px solid #000;background:#000;color:#fff}@media print{.jGrowl{display:none}}
\ No newline at end of file
diff --git a/share/static/css/base/main.css b/share/static/css/base/main.css
index 7cc2906..40a321a 100644
--- a/share/static/css/base/main.css
+++ b/share/static/css/base/main.css
@@ -32,3 +32,4 @@
 @import "dropzone.css";
 @import "dropzone.customized.css";
 @import "keyboard-shortcuts.css";
+ at import "jquery.jgrowl.min.css";
diff --git a/share/static/js/jquery.jgrowl.min.js b/share/static/js/jquery.jgrowl.min.js
new file mode 100644
index 0000000..418c3db
--- /dev/null
+++ b/share/static/js/jquery.jgrowl.min.js
@@ -0,0 +1,2 @@
+!function(a){a.jGrowl=function(b,c){0===a("#jGrowl").length&&a('<div id="jGrowl"></div>').addClass(c&&c.position?c.position:a.jGrowl.defaults.position).appendTo(c&&c.appendTo?c.appendTo:a.jGrowl.defaults.appendTo),a("#jGrowl").jGrowl(b,c)},a.fn.jGrowl=function(b,c){if(void 0===c&&a.isPlainObject(b)&&(c=b,b=c.message),a.isFunction(this.each)){var d=arguments;return this.each(function(){void 0===a(this).data("jGrowl.instance")&&(a(this).data("jGrowl.instance",a.extend(new a.fn.jGrowl,{notifications:[],element:null,interval:null})),a(this).data("jGrowl.instance").startup(this)),a.isFunction(a(this).data("jGrowl.instance")[b])?a(this).data("jGrowl.instance")[b].apply(a(this).data("jGrowl.instance"),a.makeArray(d).slice(1)):a(this).data("jGrowl.instance").create(b,c)})}},a.extend(a.fn.jGrowl.prototype,{defaults:{pool:0,header:"",group:"",sticky:!1,position:"top-right",appendTo:"body",glue:"after",theme:"default",themeState:"highlight",corners:"10px",check:250,life:3e3,closeDuration:"norm
 al",openDuration:"normal",easing:"swing",closer:!0,closeTemplate:"×",closerTemplate:"<div>[ close all ]</div>",log:function(){},beforeOpen:function(){},afterOpen:function(){},open:function(){},beforeClose:function(){},close:function(){},click:function(){},animateOpen:{opacity:"show"},animateClose:{opacity:"hide"}},notifications:[],element:null,interval:null,create:function(b,c){var d=a.extend({},this.defaults,c);"undefined"!=typeof d.speed&&(d.openDuration=d.speed,d.closeDuration=d.speed),this.notifications.push({message:b,options:d}),d.log.apply(this.element,[this.element,b,d])},render:function(b){var c=this,d=b.message,e=b.options;e.themeState=""===e.themeState?"":"ui-state-"+e.themeState;var f=a("<div/>").addClass("jGrowl-notification alert "+e.themeState+" ui-corner-all"+(void 0!==e.group&&""!==e.group?" "+e.group:"")).append(a("<button/>").addClass("jGrowl-close").html(e.closeTemplate)).append(a("<div/>").addClass("jGrowl-header").html(e.header)).append(a("<div/>").addCla
 ss("jGrowl-message").html(d)).data("jGrowl",e).addClass(e.theme).children(".jGrowl-close").bind("click.jGrowl",function(){return a(this).parent().trigger("jGrowl.beforeClose"),!1}).parent();a(f).bind("mouseover.jGrowl",function(){a(".jGrowl-notification",c.element).data("jGrowl.pause",!0)}).bind("mouseout.jGrowl",function(){a(".jGrowl-notification",c.element).data("jGrowl.pause",!1)}).bind("jGrowl.beforeOpen",function(){e.beforeOpen.apply(f,[f,d,e,c.element])!==!1&&a(this).trigger("jGrowl.open")}).bind("jGrowl.open",function(){e.open.apply(f,[f,d,e,c.element])!==!1&&("after"==e.glue?a(".jGrowl-notification:last",c.element).after(f):a(".jGrowl-notification:first",c.element).before(f),a(this).animate(e.animateOpen,e.openDuration,e.easing,function(){a.support.opacity===!1&&this.style.removeAttribute("filter"),null!==a(this).data("jGrowl")&&"undefined"!=typeof a(this).data("jGrowl")&&(a(this).data("jGrowl").created=new Date),a(this).trigger("jGrowl.afterOpen")}))}).bind("jGrowl.afterOpe
 n",function(){e.afterOpen.apply(f,[f,d,e,c.element])}).bind("click",function(){e.click.apply(f,[f,d,e,c.element])}).bind("jGrowl.beforeClose",function(){e.beforeClose.apply(f,[f,d,e,c.element])!==!1&&a(this).trigger("jGrowl.close")}).bind("jGrowl.close",function(){a(this).data("jGrowl.pause",!0),a(this).animate(e.animateClose,e.closeDuration,e.easing,function(){a.isFunction(e.close)?e.close.apply(f,[f,d,e,c.element])!==!1&&a(this).remove():a(this).remove()})}).trigger("jGrowl.beforeOpen"),""!==e.corners&&void 0!==a.fn.corner&&a(f).corner(e.corners),a(".jGrowl-notification:parent",c.element).length>1&&0===a(".jGrowl-closer",c.element).length&&this.defaults.closer!==!1&&a(this.defaults.closerTemplate).addClass("jGrowl-closer "+this.defaults.themeState+" ui-corner-all").addClass(this.defaults.theme).appendTo(c.element).animate(this.defaults.animateOpen,this.defaults.speed,this.defaults.easing).bind("click.jGrowl",function(){a(this).siblings().trigger("jGrowl.beforeClose"),a.isFunction(
 c.defaults.closer)&&c.defaults.closer.apply(a(this).parent()[0],[a(this).parent()[0]])})},update:function(){a(this.element).find(".jGrowl-notification:parent").each(function(){void 0!==a(this).data("jGrowl")&&void 0!==a(this).data("jGrowl").created&&a(this).data("jGrowl").created.getTime()+parseInt(a(this).data("jGrowl").life,10)<(new Date).getTime()&&a(this).data("jGrowl").sticky!==!0&&(void 0===a(this).data("jGrowl.pause")||a(this).data("jGrowl.pause")!==!0)&&a(this).trigger("jGrowl.beforeClose")}),this.notifications.length>0&&(0===this.defaults.pool||a(this.element).find(".jGrowl-notification:parent").length<this.defaults.pool)&&this.render(this.notifications.shift()),a(this.element).find(".jGrowl-notification:parent").length<2&&a(this.element).find(".jGrowl-closer").animate(this.defaults.animateClose,this.defaults.speed,this.defaults.easing,function(){a(this).remove()})},startup:function(b){this.element=a(b).addClass("jGrowl").append('<div class="jGrowl-notification"></div>'),th
 is.interval=setInterval(function(){var c=a(b).data("jGrowl.instance");void 0!==c&&c.update()},parseInt(this.defaults.check,10))},shutdown:function(){a(this.element).removeClass("jGrowl").find(".jGrowl-notification").trigger("jGrowl.close").parent().empty(),clearInterval(this.interval)},close:function(){a(this.element).find(".jGrowl-notification").each(function(){a(this).trigger("jGrowl.beforeClose")})}}),a.jGrowl.defaults=a.fn.jGrowl.prototype.defaults}(jQuery);
+//# sourceMappingURL=jquery.jgrowl.map
\ No newline at end of file

commit 587e035e88568abcc2faedc47a575f1a9d1fed5e
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Aug 25 16:58:59 2016 +0000

    Report inline edit action results using jGrowl

diff --git a/share/static/js/util.js b/share/static/js/util.js
index 30d48f8..c710372 100644
--- a/share/static/js/util.js
+++ b/share/static/js/util.js
@@ -604,7 +604,15 @@ jQuery(function () {
         jQuery.ajax({
             url     : editor.attr('action'),
             method  : 'POST',
-            data    : params
+            data    : params,
+            success : function (results) {
+                jQuery.each(results.actions, function (i, action) {
+                    jQuery.jGrowl(action, { themeState: 'none' });
+                });
+            },
+            error   : function (xhr, error) {
+                jQuery.jGrowl(error, { sticky: true, themeState: 'none' });
+            }
         });
     });
 

commit be5756da6c1f93c5967a6b7e2832e1dc0d63a75b
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Aug 31 19:57:45 2016 +0000

    After the inline edit post comes back, refresh row
    
    If an error occurs, we don't want to show the potentially inconsistent
    data, so the contents of the table cell are replaced with the (localized)
    word "Error".

diff --git a/share/static/css/base/collection.css b/share/static/css/base/collection.css
index 1596e51..4e25347 100644
--- a/share/static/css/base/collection.css
+++ b/share/static/css/base/collection.css
@@ -47,3 +47,8 @@ td.editing.loading > form.editor > img.loading {
     height: 1em;
     width: 1em;
 }
+
+td.editing.loading.error {
+    color: red;
+    font-weight: bold;
+}
diff --git a/share/static/js/util.js b/share/static/js/util.js
index c710372..f9abdc2 100644
--- a/share/static/js/util.js
+++ b/share/static/js/util.js
@@ -612,6 +612,16 @@ jQuery(function () {
             },
             error   : function (xhr, error) {
                 jQuery.jGrowl(error, { sticky: true, themeState: 'none' });
+            },
+            complete : function () {
+                refreshCollectionListRow(
+                    cell.closest('tbody'),
+                    function () { },
+                    function (xhr, error) {
+                        jQuery.jGrowl(error, { sticky: true, themeState: 'none' });
+                        cell.addClass('error').html(loc_key('error'));
+                    }
+                );
             }
         });
     });

-----------------------------------------------------------------------


More information about the rt-commit mailing list