Review Board

beta

Add sortable columns to the dashboard and review request lists

Updated 1 year, 6 months ago

Christian Hammond Reviewers
trunk reviewboard
None Review Board SVN
Added support for sorting review requests in the dashboard and various lists (submitters, groups, all). The columns are now clickable and will cause the list to sort.

Up to three columns can be taken into consideration for sorting at any given point. We update the columns when clicked as follows:

* If the column is not in the existing sort list, it becomes the primary sort column and we display a large down arrow.
* If the column is in the existing sort list and is the primary sort column, we reverse the order of the column and display a large up arrow.
* If the column is in the sort list but is not the primary sort column, we make it the primary sort column, and we don't reverse the order.

We save a cookie every time the order changes and use that next time unless a ?sort= parameter is set.
Sorted various columns and messed with each case above. Appears to work correctly.
/trunk/reviewboard/urls.py
Revision 768 New Change
27
27
28
    (r'^$', 'django.views.generic.simple.redirect_to',
28
    (r'^$', 'django.views.generic.simple.redirect_to',
29
     {'url': '/dashboard/'}),
29
     {'url': '/dashboard/'}),
30
30
31
    # Review request browsing
31
    # Review request browsing
32
    (r'^dashboard/$', 'reviewboard.reviews.views.dashboard',
32
    (r'^dashboard/$', 'reviewboard.reviews.views.dashboard'),
33
     {'template_name': 'reviews/dashboard.html'}),
34
33
35
    (r'^r/$', 'reviewboard.reviews.views.all_review_requests',
34
    (r'^r/$', 'reviewboard.reviews.views.all_review_requests'),
36
     {'template_name': 'reviews/review_list.html'}),
37
35
38
    # Review request creation
36
    # Review request creation
39
    (r'^r/new/$', 'reviewboard.reviews.views.new_review_request'),
37
    (r'^r/new/$', 'reviewboard.reviews.views.new_review_request'),
40
38
41
    # Review request detail
39
    # Review request detail
95
     'reviewboard.reviews.views.preview_review_email'),
93
     'reviewboard.reviews.views.preview_review_email'),
96
    (r'^r/(?P<review_request_id>[0-9]+)/reviews/(?P<review_id>[0-9]+)/replies/(?P<reply_id>[0-9]+)/preview-email/$',
94
    (r'^r/(?P<review_request_id>[0-9]+)/reviews/(?P<review_id>[0-9]+)/replies/(?P<reply_id>[0-9]+)/preview-email/$',
97
     'reviewboard.reviews.views.preview_reply_email'),
95
     'reviewboard.reviews.views.preview_reply_email'),
98
96
99
    # Users
97
    # Users
100
    (r'^users/$', 'reviewboard.reviews.views.submitter_list',
98
    (r'^users/$', 'reviewboard.reviews.views.submitter_list'),
101
     {'template_name': 'reviews/submitter_list.html'}),
102
103
    (r'^users/(?P<username>[A-Za-z0-9_-]+)/$',
99
    (r'^users/(?P<username>[A-Za-z0-9_-]+)/$',
104
     'reviewboard.reviews.views.submitter',
100
     'reviewboard.reviews.views.submitter'),
105
     {'template_name': 'reviews/review_list.html'}),
106
101
107
    # Groups
102
    # Groups
108
    (r'^groups/$', 'reviewboard.reviews.views.group_list',
103
    (r'^groups/$', 'reviewboard.reviews.views.group_list'),
109
     {'template_name': 'reviews/group_list.html'}),
104
    (r'^groups/(?P<name>[A-Za-z0-9_-]+)/$', 'reviewboard.reviews.views.group'),
110
111
    (r'^groups/(?P<name>[A-Za-z0-9_-]+)/$',
112
     'reviewboard.reviews.views.group',
113
     {'template_name': 'reviews/review_list.html'}),
114
105
115
    # Feeds
106
    # Feeds
116
    (r'^feeds/rss/(?P<url>.*)/$', 'django.contrib.syndication.views.feed',
107
    (r'^feeds/rss/(?P<url>.*)/$', 'django.contrib.syndication.views.feed',
117
     {'feed_dict': rss_feeds}),
108
     {'feed_dict': rss_feeds}),
118
    (r'^feeds/atom/(?P<url>.*)/$', 'django.contrib.syndication.views.feed',
109
    (r'^feeds/atom/(?P<url>.*)/$', 'django.contrib.syndication.views.feed',
/trunk/reviewboard/accounts/models.py
Revision 768 New Change
14
14
15
    collapsed_diffs = models.BooleanField(default=True)
15
    collapsed_diffs = models.BooleanField(default=True)
16
    wordwrapped_diffs = models.BooleanField(default=True)
16
    wordwrapped_diffs = models.BooleanField(default=True)
17
    syntax_highlighting = models.BooleanField(default=True)
17
    syntax_highlighting = models.BooleanField(default=True)
18
18
19
    sort_columns = models.CharField(maxlength=128, blank=True)
20
    columns = models.CharField(maxlength=128, blank=True)
21
19
    starred_review_requests = models.ManyToManyField(ReviewRequest, core=False,
22
    starred_review_requests = models.ManyToManyField(ReviewRequest, core=False,
20
                                                     blank=True)
23
                                                     blank=True)
21
24
22
    def __str__(self):
25
    def __str__(self):
23
        return self.user.username
26
        return self.user.username
24
27
25
    class Admin:
28
    class Admin:
26
        list_display = ('__str__', 'first_time_setup_done')
29
        list_display = ('__str__', 'first_time_setup_done')
/trunk/reviewboard/htdocs/css/common.css
Revision 768 New Change
90
  background: url("/images/ui/header_bg.png") repeat-x bottom left;
90
  background: url("/images/ui/header_bg.png") repeat-x bottom left;
91
  border-top: 1px #999999 solid;
91
  border-top: 1px #999999 solid;
92
  border-bottom: 1px #999999 solid;
92
  border-bottom: 1px #999999 solid;
93
  border-left: 1px #CCCCCC solid;
93
  border-left: 1px #CCCCCC solid;
94
  border-right: 1px #CCCCCC solid;
94
  border-right: 1px #CCCCCC solid;
95
  color: #444444;
96
  cursor: pointer;
97
}
98
99
table.list tr.headers th a {
100
  color: black;
101
  text-decoration: none;
102
}
103
104
table.list tr.headers th a.unsort {
105
  color: #444444;
106
}
107
108
table.list tr.headers th a:hover {
109
  text-decoration: underline;
95
}
110
}
96
111
97
table.list tr:hover.headers {
112
table.list tr:hover.headers {
98
  background-color: transparent;
113
  background-color: transparent;
99
}
114
}
100
115
116
table.list tr.headers th:hover {
117
  background: url("/images/ui/header_bg_primary.png") repeat-x bottom left;
118
}
119
120
table.list tr.headers th:hover a {
121
  text-decoration: underline;
122
}
123
101
table.list tr:hover .age1 {
124
table.list tr:hover .age1 {
102
  background: #abd5a9;
125
  background: #abd5a9;
103
}
126
}
104
127
105
table.list tr:hover .age2 {
128
table.list tr:hover .age2 {
/trunk/reviewboard/reviews/views.py
Revision 768 New Change
1
from urllib import quote
1
from urllib import quote
2
2
3
from django.conf import settings
3
from django.conf import settings
4
from django.contrib.auth import REDIRECT_FIELD_NAME
4
from django.contrib.auth import REDIRECT_FIELD_NAME
5
from django.contrib.auth.models import User
5
from django.contrib.auth.models import User
6
from django.contrib.sites.models import Site
6
from django.contrib.sites.models import Site
7
from django.db.models import Q
7
from django.db.models import Q
8
from django.http import HttpResponse, HttpResponseRedirect, Http404, \
8
from django.http import HttpResponse, HttpResponseRedirect, Http404, \
9
                        HttpResponseForbidden
9
                        HttpResponseForbidden
10
from django.shortcuts import get_object_or_404, render_to_response
10
from django.shortcuts import get_object_or_404, render_to_response
11
from django.template.context import RequestContext
11
from django.template.context import RequestContext
12
from django.template.loader import render_to_string
12
from django.template.loader import render_to_string
13
from django.utils import simplejson
13
from django.utils import simplejson
14
from django.views.generic.list_detail import object_list
15
from djblets.auth.util import login_required
14
from djblets.auth.util import login_required
16
from djblets.util.decorators import simple_decorator
15
from djblets.util.decorators import simple_decorator
17
from djblets.util.misc import get_object_or_none
16
from djblets.util.misc import get_object_or_none
18
17
19
from reviewboard.accounts.models import Profile
18
from reviewboard.accounts.models import Profile
27
                                      UploadScreenshotForm, \
26
                                      UploadScreenshotForm, \
28
                                      OwnershipError
27
                                      OwnershipError
29
from reviewboard.reviews.email import mail_review_request, \
28
from reviewboard.reviews.email import mail_review_request, \
30
                                      mail_diff_update
29
                                      mail_diff_update
31
from reviewboard.scmtools.models import Repository
30
from reviewboard.scmtools.models import Repository
31
from reviewboard.utils.views import sortable_object_list
32
import reviewboard.reviews.db as reviews_db
32
import reviewboard.reviews.db as reviews_db
33
33
34
34
35
@simple_decorator
35
@simple_decorator
36
def valid_prefs_required(view_func):
36
def valid_prefs_required(view_func):
103
        'request': request,
103
        'request': request,
104
    }))
104
    }))
105
105
106
106
107
def review_list(request, queryset, template_name, extra_context={}):
107
def review_list(request, queryset, template_name, extra_context={}):
108
    return object_list(request,
108
    return sortable_object_list(request,
109
        queryset=queryset.filter(Q(status='P') |
109
        queryset=queryset.filter(Q(status='P') |
110
                                 Q(status='S')).order_by('-last_updated'),
110
                                 Q(status='S')).order_by('-last_updated'),
111
        paginate_by=50,
112
        allow_empty=True,
113
        template_name=template_name,
111
        template_name=template_name,
114
        extra_context=dict(
112
        extra_context=extra_context)
115
            {'app_path': request.path},
116
            **extra_context
117
        ))
118
113
119
114
120
@login_required
115
@login_required
121
def all_review_requests(request, template_name):
116
def all_review_requests(request, template_name='reviews/review_list.html'):
122
    return review_list(request,
117
    return review_list(request,
123
        queryset=reviews_db.get_all_review_requests(request.user, status=None),
118
        queryset=reviews_db.get_all_review_requests(request.user, status=None),
124
        template_name=template_name)
119
        template_name=template_name)
125
120
126
121
127
@login_required
122
@login_required
128
def submitter_list(request, template_name):
123
def submitter_list(request, template_name='reviews/submitter_list.html'):
129
    return object_list(request,
124
    return sortable_object_list(request,
130
        queryset=User.objects.filter(),
125
        queryset=User.objects.filter(),
131
        template_name=template_name,
126
        template_name=template_name)
132
        paginate_by=50,
133
        allow_empty=True,
134
        extra_context={
135
            'app_path': request.path,
136
        })
137
127
138
128
139
@login_required
129
@login_required
140
def group_list(request, template_name):
130
def group_list(request, template_name='reviews/group_list.html'):
141
    return object_list(request,
131
    return sortable_object_list(request,
142
        queryset=Group.objects.all(),
132
        queryset=Group.objects.all(),
143
        template_name=template_name,
133
        template_name=template_name)
144
        paginate_by=50,
145
        allow_empty=True,
146
        extra_context={
147
            'app_path': request.path,
148
        })
149
134
150
135
151
@login_required
136
@login_required
152
@valid_prefs_required
137
@valid_prefs_required
153
def dashboard(request, template_name='reviews/dashboard.html'):
138
def dashboard(request, template_name='reviews/dashboard.html'):
154
    view = request.GET.get('view', 'incoming')
139
    view = request.GET.get('view', 'incoming')
155
    group = request.GET.get('group', "")
140
    group = request.GET.get('group', "")
156
141
157
    if view == 'outgoing':
142
    if view == 'outgoing':
158
        review_requests = \
143
        review_requests = \
159
            reviews_db.get_review_requests_from_user(request.user.username,
144
            reviews_db.get_review_requests_from_user(request.user.username,
160
                                                     request.user)
145
                                                     request.user)
161
        title = "All Outgoing Review Requests";
146
        title = "All Outgoing Review Requests"
162
    elif view == 'to-me':
147
    elif view == 'to-me':
163
        review_requests = reviews_db.get_review_requests_to_user_directly(
148
        review_requests = reviews_db.get_review_requests_to_user_directly(
164
            request.user.username, request.user)
149
            request.user.username, request.user)
165
        title = "Incoming Review Requests to Me";
150
        title = "Incoming Review Requests to Me"
166
    elif view == 'to-group':
151
    elif view == 'to-group':
167
        if group != "":
152
        if group != "":
168
            review_requests = reviews_db.get_review_requests_to_group(
153
            review_requests = reviews_db.get_review_requests_to_group(
169
                group, request.user)
154
                group, request.user)
170
            title = "Incoming Review Requests to %s" % group;
155
            title = "Incoming Review Requests to %s" % group
171
        else:
156
        else:
172
            review_requests = reviews_db.get_review_requests_to_user_groups(
157
            review_requests = reviews_db.get_review_requests_to_user_groups(
173
                request.user.username, request.user)
158
                request.user.username, request.user)
174
            title = "All Incoming Review Requests to My Groups"
159
            title = "All Incoming Review Requests to My Groups"
175
    else: # "incoming" or invalid
160
    else: # "incoming" or invalid
183
        list is a QuerySet.
168
        list is a QuerySet.
184
        """
169
        """
185
        def __init__(self, list):
170
        def __init__(self, list):
186
            self.list = list
171
            self.list = list
187
172
173
        def order_by(self, *field_names):
174
            return BogusQuerySet(sorted(self.list,
175
                lambda a,b: self._sort_func(a, b, field_names)))
176
177
        def _sort_func(self, a, b, field_list):
178
            for field in field_list:
179
                if field[0] == "-":
180
                    reverse = True
181
                    field = field[1:]
182
                else:
183
                    reverse = False
184
185
                try:
186
                    a_value = str(getattr(a, field))
187
                    b_value = str(getattr(b, field))
188
189
                    if reverse:
190
                        i = cmp(b_value, a_value)
191
                    else:
192
                        i = cmp(a_value, b_value)
193
194
                    if i != 0:
195
                        return i
196
                except AttributeError:
197
                    # The field doesn't exist, so just ignore it.
198
                    pass
199
200
            # They're equal, so compare the objects themselves to "sort" it out.
201
            return cmp(a, b)
202
188
        def _clone(self):
203
        def _clone(self):
189
            return self.list
204
            return self.list
190
205
191
    return object_list(request,
206
    if isinstance(review_requests, list):
192
        queryset=BogusQuerySet(review_requests),
207
        queryset = BogusQuerySet(review_requests)
208
    else:
209
        queryset = review_requests
210
211
    return sortable_object_list(request,
212
        queryset=queryset,
193
        template_name=template_name,
213
        template_name=template_name,
194
        paginate_by=50,
195
        allow_empty=True,
196
        template_object_name='review_request',
214
        template_object_name='review_request',
197
        extra_context={
215
        extra_context={
198
            'title': title,
216
            'title': title,
199
            'view': view,
217
            'view': view,
200
            'group': group,
218
            'group': group,
201
        })
219
        })
202
220
203
221
204
@login_required
222
@login_required
205
def group(request, name, template_name):
223
def group(request, name, template_name='reviews/review_list.html'):
206
    return review_list(request,
224
    return review_list(request,
207
        queryset=reviews_db.get_review_requests_to_group(name, status=None),
225
        queryset=reviews_db.get_review_requests_to_group(name, status=None),
208
        template_name=template_name,
226
        template_name=template_name,
209
        extra_context={
227
        extra_context={
210
            'source': name,
228
            'source': name,
211
        })
229
        })
212
230
213
231
214
@login_required
232
@login_required
215
def submitter(request, username, template_name):
233
def submitter(request, username, template_name='reviews/review_list.html'):
216
    return review_list(request,
234
    return review_list(request,
217
        queryset=reviews_db.get_review_requests_from_user(username,
235
        queryset=reviews_db.get_review_requests_from_user(username,
218
                                                          status=None),
236
                                                          status=None),
219
        template_name=template_name,
237
        template_name=template_name,
220
        extra_context={
238
        extra_context={
/trunk/reviewboard/templates/reviews/dashboard.html
Revision 768 New Change
42
    <col class="posted" width="0%" />
42
    <col class="posted" width="0%" />
43
    <col class="updated" width="0%" />
43
    <col class="updated" width="0%" />
44
   </colgroup>
44
   </colgroup>
45
   <thead>
45
   <thead>
46
    <tr class="headers">
46
    <tr class="headers">
47
     <th>Summary</th>
47
     {% column_header "summary"      "Summary"      %}
48
     <th>Submitter</th>
48
     {% column_header "submitter"    "Submitter"    %}
49
     <th>Posted</th>
49
     {% column_header "time_added"   "Posted"       %}
50
     <th>Last Updated</th>
50
     {% column_header "last_updated" "Last Updated" %}
51
    </tr>
51
    </tr>
52
   </thead>
52
   </thead>
53
   <tbody>
53
   <tbody>
54
{% for review_request in review_request_list %}
54
{% for review_request in review_request_list %}
55
    <tr>
55
    <tr>
/trunk/reviewboard/templates/reviews/review_list.html
Revision 768 New Change
19
  <col class="submitter" width="0%" />
19
  <col class="submitter" width="0%" />
20
  <col class="updated" width="0%" />
20
  <col class="updated" width="0%" />
21
 </colgroup>
21
 </colgroup>
22
 <thead>
22
 <thead>
23
  <tr class="headers">
23
  <tr class="headers">
24
   <th>Summary</th>
24
   {% column_header "summary"      "Summary"      %}
25
   <th>Submitter</th>
25
   {% column_header "submitter"    "Submitter"    %}
26
   <th>Posted</th>
26
   {% column_header "time_added"   "Posted"       %}
27
   <th>Last Updated</th>
27
   {% column_header "last_updated" "Last Updated" %}
28
  </tr>
28
  </tr>
29
 </thead>
29
 </thead>
30
 <tbody>
30
 <tbody>
31
{%  for review in object_list %}
31
{%  for review in object_list %}
32
  <tr>
32
  <tr>
/trunk/reviewboard/utils/views.py
New File
1
from django.views.generic.list_detail import object_list
2
3
from reviewboard.accounts.models import Profile
4
5
6
def sortable_object_list(request, queryset, extra_context={}, *args, **kwargs):
7
    profile, profile_is_new = \
8
        Profile.objects.get_or_create(user=request.user)
9
10
    sort_list = None
11
    sort = request.GET.get('sort', profile.sort_columns)
12
13
    if sort:
14
        sort_list = sort.split(',')
15
        queryset = queryset.order_by(*sort_list)
16
17
    response = object_list(request,
18
                           queryset=queryset,
19
                           paginate_by=50,
20
                           allow_empty=True,
21
                           extra_context=dict(
22
                               {'app_path': request.path,
23
                                'sort_list': sort_list},
24
                               **extra_context
25
                           ),
26
                           *args, **kwargs)
27
28
    if profile.sort_columns != sort:
29
        profile.sort_columns = sort
30
        profile.save()
31
32
    return response
/trunk/reviewboard/utils/templatetags/htmlutils.py
Revision 768 New Change
1
# vim: set fileencoding=utf-8 :
1
import datetime
2
import datetime
2
import Image
3
import Image
3
import os
4
import os
4
5
5
from django.conf import settings
6
from django import template
6
from django import template
7
from django.conf import settings
8
from django.template import resolve_variable
9
from django.template import TemplateSyntaxError, VariableDoesNotExist
7
from djblets.util.decorators import blocktag
10
from djblets.util.decorators import blocktag
8
11
9
12
10
register = template.Library()
13
register = template.Library()
11
14
12
15
16
class ColumnHeader(template