Reimplement talkvote page in a more flexible way
authorMagnus Hagander <magnus@hagander.net>
Mon, 11 Aug 2025 12:43:25 +0000 (14:43 +0200)
committerMagnus Hagander <magnus@hagander.net>
Mon, 11 Aug 2025 12:43:25 +0000 (14:43 +0200)
This reimplements the talkvote page mostly client-side, making it
possible to do all the filtering without an expensive server roundtrip
(it gets expensive to do this due to the page being big for large
 conferences). Also implement column on/off checkboxes.

This also means getting rid of the bootstrap accordion controls, in
favor of native CSS, which as a bonus is faster.

Based on ideas from Karen Jex, and several years of discussions.

Fixes #193, along with many other things

docs/confreg/callforpapers.md
postgresqleu/confreg/views.py
template/confreg/sessionvotes.html

index e7bdb472cf9fffb5d025cabf79b772ce7b695acd..5a227971a9302bf4122bfed76ff510cecf012b1b 100644 (file)
@@ -45,8 +45,10 @@ Click the talk title to view full details about the talk.
 
 Click the status to bring up a dialog allowing the change of status.
 
-Sessions can be sorted by session name (default), speakers or average
-score by clicking the appropriate headlines.
+Sessions can be sorted by most session fields (default is to sort
+by session name) by clicking the column header.
+
+Columns can be turned on and off in the dislpay using the checkboxes.
 
 #### 2.1 Scoring method
 
index 9bbe30e1ef1952ba3fe4d8792cd81768aab5a4ad..4b5a301c4073cbf3d904b018ea53b2b42a28f1a3 100644 (file)
@@ -2835,44 +2835,9 @@ def talkvote(request, confname):
     hasvoters = conference.talkvoters.exists()
     alltracks = [{'id': t.id, 'trackname': t.trackname} for t in Track.objects.filter(conference=conference)]
     alltracks.insert(0, {'id': 0, 'trackname': 'No track'})
-    alltrackids = [t['id'] for t in alltracks]
-    selectedtracks = [int(id) for id in request.GET.getlist('tracks') if int(id) in alltrackids]
     alltags = [{'id': t.id, 'tag': t.tag} for t in ConferenceSessionTag.objects.filter(conference=conference)]
     alltags.insert(0, {'id': 0, 'tag': 'No tag'})
-    alltagids = [t['id'] for t in alltags]
-    selectedtags = [int(id) for id in request.GET.getlist('tags') if int(id) in alltagids]
     allstatusids = [id for id, status in STATUS_CHOICES]
-    selectedstatuses = [int(id) for id in request.GET.getlist('statuses') if int(id) in allstatusids]
-    if selectedtracks:
-        urltrackfilter = "{0}&".format("&".join(["tracks={0}".format(t) for t in selectedtracks]))
-    else:
-        selectedtracks = alltrackids
-        urltrackfilter = ''
-
-    if selectedtags:
-        urltagfilter = "{0}&".format("&".join(["tags={0}".format(t) for t in selectedtags]))
-    else:
-        selectedtags = alltagids
-        urltagfilter = ''
-
-    if selectedstatuses:
-        urlstatusfilter = "{0}&".format("&".join(["statuses={0}".format(t) for t in selectedstatuses]))
-    else:
-        selectedstatuses = allstatusids
-        urlstatusfilter = ''
-
-    nonvoted = request.GET.get('nonvoted', '0') == '1'
-    if nonvoted:
-        nonvotedquery = "AND NOT EXISTS (SELECT 1 FROM confreg_conferencesessionvote nv WHERE nv.session_id=s.id AND nv.voter_id=%(userid)s AND nv.vote <> 0)"
-    else:
-        nonvotedquery = ""
-
-    if conference.callforpaperstags:
-        tagsquery = "AND (EXISTS (SELECT 1 FROM confreg_conferencesession_tags cst WHERE cst.conferencesession_id=s.id AND (cst.conferencesessiontag_id=ANY(%(tags)s))) {})".format(
-            'OR NOT EXISTS (SELECT 1 FROM confreg_conferencesession_tags cstt WHERE cstt.conferencesession_id=s.id)' if 0 in selectedtags else '',
-        )
-    else:
-        tagsquery = ""
 
     curs = connection.cursor()
     curs.execute("SELECT username FROM confreg_conference_talkvoters INNER JOIN auth_user ON user_id=auth_user.id WHERE conference_id=%(confid)s ORDER BY 1", {
@@ -2880,17 +2845,6 @@ def talkvote(request, confname):
     })
     allusers = [u for u, in curs.fetchall()]
 
-    order = ""
-    if 'sort' in request.GET:
-        if request.GET["sort"] == "avg":
-            order = "avg DESC NULLS LAST,"
-        elif request.GET["sort"] == "speakers":
-            order = "speakerdata#>>'{0,fullname}', avg DESC NULLS LAST,"
-        elif request.GET["sort"] == "session":
-            order = "s.title, avg DESC NULLS LAST,"
-    else:
-        order = "s.id,"
-
     # Render the form. Need to do this with a manual query, can't figure
     # out the right way to do it with the django ORM.
     sessiondata = exec_to_dict("""SELECT
@@ -2901,11 +2855,20 @@ def talkvote(request, confname):
   avg,
   COALESCE(votes, '{{}}'::jsonb) AS votes,
   jsonb_build_object(%(username)s, '') || COALESCE(comments, '{{}}'::jsonb) AS comments,
-  trackname, recordingconsent,
-  (SELECT array_agg(tag) FROM confreg_conferencesessiontag t INNER JOIN confreg_conferencesession_tags cst ON cst.conferencesessiontag_id=t.id WHERE cst.conferencesession_id=s.id) AS tags
+  track_id AS trackid, trackname, recordingconsent,
+  tags.tags
 FROM confreg_conferencesession s
 INNER JOIN confreg_status_strings status ON status.id=s.status
 LEFT JOIN confreg_track track ON track.id=s.track_id
+LEFT JOIN LATERAL (
+    SELECT json_agg(json_build_object(
+       'id', tag.id,
+       'tag', tag.tag
+    ) ORDER BY tag) as tags
+    FROM confreg_conferencesessiontag tag
+    INNER JOIN confreg_conferencesession_tags cst ON cst.conferencesessiontag_id=tag.id
+    WHERE cst.conferencesession_id=s.id
+) tags ON true
 LEFT JOIN LATERAL (
     SELECT json_agg(json_build_object(
        'id', spk.id,
@@ -2948,17 +2911,11 @@ LEFT JOIN LATERAL (
         comments
     FROM aggs
 ) votes ON true
-WHERE s.conference_id=%(confid)s AND
-      (COALESCE(s.track_id,0)=ANY(%(tracks)s)) AND
-      status=ANY(%(statuses)s)
-      {} {}
-ORDER BY {}s.title,s.id""".format(nonvotedquery, tagsquery, order), {
+WHERE s.conference_id=%(confid)s
+ORDER BY s.id""".format(), {
         'confid': conference.id,
         'userid': request.user.id,
         'username': request.user.username,
-        'tracks': selectedtracks,
-        'statuses': selectedstatuses,
-        'tags': selectedtags,
     })
 
     # If the user is only talkvoter at the conference, and not an administrator,
@@ -2974,6 +2931,27 @@ ORDER BY {}s.title,s.id""".format(nonvotedquery, tagsquery, order), {
     }
     options = [(x, options_text.get(x, str(x))) for x in range(-1, 10)]
 
+    # This belongs in the template, but django won't let us declare complex variables there
+    colcount = 7  # seq, id, title, speakers, companies, status, comments
+    filtercolumns = [
+        {'class': 'seq', 'title': 'Sequence', 'default': True},
+        {'class': 'id', 'title': 'Id', 'default': False},
+        {'class': 'spk', 'title': 'Speakers', 'default': True},
+        {'class': 'comp', 'title': 'Companies', 'default': False},
+        {'class': 'trk', 'title': 'Track', 'default': False},
+    ]
+    if conference.callforpaperstags:
+        filtercolumns.append({'class': 'tag', 'title': 'Tags', 'default': False})
+    filtercolumns.append({'class': 'votes', 'title': 'Votes', 'default': True})
+    if isadmin or conference.showvotes:
+        filtercolumns.append({'class': 'avg', 'title': 'Average', 'default': True})
+        # Add one column for average, and one for every user
+        colcount += 1 + len(allusers)
+    elif isvoter:
+        # Just add a column for this very user
+        colcount += 1
+    filtercolumns.append({'class': 'cmt', 'title': 'Comments', 'default': True})
+
     return render(request, 'confreg/sessionvotes.html', {
         'users': allusers,
         'sessionvotes': sessiondata,
@@ -2984,14 +2962,11 @@ ORDER BY {}s.title,s.id""".format(nonvotedquery, tagsquery, order), {
         'status_choices': STATUS_CHOICES,
         'tracks': alltracks,
         'tags': alltags,
-        'selectedtracks': selectedtracks,
-        'selectedtags': selectedtags,
-        'selectedstatuses': selectedstatuses,
-        'nonvoted': nonvoted,
         'valid_status_transitions': valid_status_transitions,
-        'urlfilter': urltrackfilter + urlstatusfilter + urltagfilter,
         'helplink': 'callforpapers',
         'options': options,
+        'filtercolumns': filtercolumns,
+        'colcount': colcount,
         'scoring_method': SCORING_METHOD_CHOICES[conference.scoring_method][1],
     })
 
index 38cc0e3db5ae78fcc2577711dc095570a2eb73c9..ba68118e7e2b22b00e69119209a661a9a566686d 100644 (file)
@@ -8,18 +8,10 @@
 {%asset "js" "jqueryui1" %}
 {%asset "js" "selectize" %}
 {%asset "css" "selectize" %}
+{%asset "css" "fontawesome4" %}
 
 <script type="text/javascript">
 $(function() {
-  $('.talkaccd').accordion({
-     'collapsible': true,
-     'active': false,
-     'heightStyle': 'content',
-     'animate': {
-        duration: 100,
-     },
-  });
-
   $('#dlgStatus').dialog({
      autoOpen: false,
      modal: true,
@@ -38,24 +30,151 @@ $(function() {
      }
   });
 
-  $('#selectTracks').selectize({
-      plugins: ['remove_button'],
+  $('#ajaxStatus').hide();
+
+  document.querySelectorAll('h3:has(label.dropdown-checkbox').forEach((h) => {
+      h.addEventListener('click', (e) => {
+          e.target.getElementsByTagName('input')[0].checked ^= 1;
+      });
   });
-{%if conference.callforpaperstags%}
-  $('#selectTags').selectize({
-      plugins: ['remove_button'],
+
+  document.querySelectorAll('a.filteronly').forEach((a) => {
+      a.addEventListener('click', (e) => {
+          e.target.parentElement.querySelectorAll('input.filtercheck').forEach((c) => {
+              c.checked = c.nextElementSibling == e.target;
+          });
+          e.target.parentElement.querySelector('input.filterall').checked = false;
+          filter_sessions();
+      });
   });
-{%endif%}
-  $('#selectStatuses').selectize({
-      plugins: ['remove_button'],
+
+  document.querySelectorAll('input.filterall').forEach((cb) => {
+      cb.addEventListener('change', (e) => {
+          if (e.target.checked) {
+              e.target.parentElement.querySelectorAll('input.filtercheck').forEach((c) => {
+                  c.checked = true;
+              });
+          } else {
+              e.target.checked = true;
+          }
+          filter_sessions();
+      });
   });
 
-  $('#ajaxStatus').hide();
+  document.querySelectorAll('input.filtercheck').forEach((cb) => {
+      cb.addEventListener('change', (e) => {
+          filter_sessions();
+
+          /* Update the "all" checkbox if needed */
+          const filterall = e.target.parentElement.querySelector('input.filterall');
+          if (filterall) {
+              if (e.target.parentElement.querySelectorAll('input.filtercheck:not(:checked)').length) {
+                  filterall.checked = false;
+              } else {
+                  filterall.checked = true;
+              }
+          }
+      });
+  });
 
-  /* Initially hidden so it can render in peace without us watching */
-  $('#votetable').show();
+  document.querySelectorAll('a.sortheader').forEach((a) => {
+      a.addEventListener('click', (e) => {
+          /* re-sort based on this column */
+          let colnum = e.target.parentElement.cellIndex;
+          const numsort = e.target.classList.contains('sortnumber');
+          const table = document.getElementById('votetable');
+          let sortdirection = 1;
+
+          if (e.target.dataset.sorted) {
+              sortdirection = e.target.dataset.sorted * -1;
+          }
+
+          Array.from(table.tBodies).sort((a, b) => {
+              if (a.classList.contains('header'))
+                  return -1;
+              if (b.classList.contains('header'))
+                  return 1;
+              if (numsort) {
+                  return (a.rows[0].cells[colnum].textContent - b.rows[0].cells[colnum].textContent) * sortdirection;
+              }
+              /* Else case-insensitive alpha sort */
+              const ta = a.rows[0].cells[colnum].textContent.toUpperCase();
+              const tb = b.rows[0].cells[colnum].textContent.toUpperCase();
+              if (ta > tb)
+                  return 1 * sortdirection;
+              if (ta < tb)
+                  return -1 * sortdirection;
+              return 0;
+          }).forEach(tb => table.appendChild(tb));
+
+          table.querySelectorAll('a.sortheader').forEach((a) => {
+              a.dataset.sorted = (a == e.target) ? sortdirection : '';
+          });
+
+          /* Need this to update the sequence number properly */
+          filter_sessions();
+      });
+  });
+
+  filter_sessions();
 });
 
+function filter_sessions() {
+    /* Get all our statuses */
+    const statuses = [...document.querySelectorAll('input[type=checkbox].filtercheck_status:checked')].map((cb) => parseInt(cb.id.replace('st_', '')));
+    const tracks = [...document.querySelectorAll('input[type=checkbox].filtercheck_track:checked')].map((cb) => parseInt(cb.id.replace('t_', '')));
+    const tags = [...document.querySelectorAll('input[type=checkbox].filtercheck_tag:checked')].map((cb) => parseInt(cb.id.replace('tg_', '')));
+    const votedlimit = document.querySelector('input[type=checkbox]#vt_1:checked');
+
+    let seq = 1;
+    /* Recalculate visibility and sequence for all sessions */
+    [...document.querySelectorAll('table#votetable tr.sessionrow')].forEach((row) => {
+        /* Default is everything is visible, and then we remove */
+        let visible = true;
+
+        if (!statuses.includes(parseInt(row.dataset.status))) {
+            visible = false;
+        }
+
+        if (!tracks.includes(parseInt(row.dataset.track))) {
+            visible = false;
+        }
+
+        if (document.querySelector('input.filtercheck_tag')) {
+            if (row.dataset.tags) {
+                /* If *any* of the specified tags exist we're ok */
+                let found = false;
+                row.dataset.tags.split(",").map(t => parseInt(t)).forEach(t => {
+                    if (tags.includes(t)) {
+                        found = true;
+                    }
+                });
+                if (!found) {
+                    visible = false;
+                }
+            } else {
+                if (!tags.includes(0)) {
+                    visible = false;
+                }
+            }
+        }
+
+        if (votedlimit && row.querySelector('td[data-voted="yes"]')) {
+            visible = false;
+        }
+
+        row.style.display = visible ? "table-row" : "none";
+        document.getElementById('detailsrow_' + row.dataset.sid).style.display = visible ? "" : "none";
+
+        if (visible) {
+            row.querySelector('td').innerText = seq;
+            seq += 1;
+        } else {
+            row.querySelector('td').innerText = '';
+        }
+    });
+}
+
 function showDialog(id, title) {
    if ($('#popup_' + id).dialog('isOpen')) {
       $('#popup_' + id).dialog('close');
@@ -142,7 +261,8 @@ function castVote(sessionid) {
          'vote': val,
       },
       function(data) {
-           p.css('background-color', (val==0)?'red':'white');
+           p.attr('data-voted', (val==0)?"no":"yes");
+           p.css('background-color', '');
            avgbox.html(data);
       }
    ).fail(function() {
@@ -196,11 +316,6 @@ div.dlg {
   display: none;
 }
 
-div.filterButtonRow {
-  margin-top: 1em;
-  margin-bottom: 1.5em;
-}
-
 /* Override bootstrap to make full screen */
 .container {
 width: 100%;
@@ -213,47 +328,123 @@ ul.comments {
 ul.comments span.username {
     font-weight: bold;
 }
+
+label.dropdown-checkbox input[type=checkbox] {
+    display: none;
+}
+label.dropdown-checkbox input[type=checkbox] + span {
+    display: inline-block;
+    text-align: center;
+}
+label.dropdown-checkbox input[type=checkbox]:checked + span::after {
+    content: "\f0d7";
+    font-family: FontAwesome;
+}
+label.dropdown-checkbox input[type=checkbox]:not(:checked) + span::after {
+    content: "\f0da";
+    font-family: FontAwesome;
+}
+h3:has(label.dropdown-checkbox),
+label.dropdown-checkbox {
+    cursor: pointer;
+}
+
+td[data-voted="no"] {
+    background-color: red;
+}
+
+
+tr.detailsrow td {
+    text-align: center;
+}
+tr.detailsrow td div.detailscontent {
+    max-width: 800px;
+    text-align: left;
+    display: inline-block;
+}
+
+tr.detailsrow  {
+    display: none;
+}
+tr.headerrow:has(td label.dropdown-checkbox input[type=checkbox]:checked) + tr {
+    display: table-row;
+}
+
+{% for fc in filtercolumns %}
+tr.headerrow td.flt-{{fc.class}},
+tr.headerrow th.flt-{{fc.class}} {
+    display: none;
+}
+body:has(input#col_{{fc.class}}:checked) tr.headerrow td.flt-{{fc.class}},
+body:has(input#col_{{fc.class}}:checked) tr.headerrow th.flt-{{fc.class}} {
+   display: table-cell;
+}
+{% endfor%}
+
+tr.headerrow {
+    display: none;
+}
+tr.headerrow.tableheader {
+    display: table-row;
+}
+
+a.filteronly {
+    cursor: pointer;
+}
+
+a.sortheader {
+    cursor: pointer;
+}
+a.sortheader::after {
+    font-family: FontAwesome;
+}
+a.sortheader[data-sorted="1"]::after {
+    content: " \f160";
+}
+a.sortheader[data-sorted="-1"]::after {
+    content: " \f161";
+}
 </style>
 {%endblock%}
 {%block layoutblock%}
 <h1>Vote for sessions - {{conference}}</h1>
 
-<form method="get" style="margin-bottom: 10px">
+<fieldset>
+  <legend>Filter sessions</legend>
  <table role="presentation" border="0" width="100%" style="border-spacing: 2rem 0.3rem; border-collapse: separate;">
   <tr>
     <td style="width: 1px;">Track:</td>
-    <td><select id="selectTracks" name="tracks" multiple="multiple" style="inline-block; width=80%;">
-{%for t in tracks%}
-      <option value="{{t.id}}"{%if t.id in selectedtracks%} SELECTED{%endif%}>{{t.trackname}}</option>
-{%endfor%}
-    </select></td>
+    <td>
+      {% for t in tracks %}<input type="checkbox" class="filtercheck filtercheck_track" id="t_{{t.id}}" checked> {{t.trackname}} <a class="filteronly">[only]</a> {% endfor %}
+      <input type="checkbox" class="filterall" checked> All
+    </td>
   </tr>
 {%if conference.callforpaperstags %}
   <tr>
     <td>Tags:</td>
-    <td><select id="selectTags" name="tags" multiple="multiple" style="inline-block; width=80%;">
-{%for t in tags %}
-      <option value="{{t.id}}"{%if t.id in selectedtags%} SELECTED{%endif%}>{{t.tag}}</option>
-{%endfor%}
-    </select></td>
+    <td>
+      {% for t in tags %}<input type="checkbox" class="filtercheck filtercheck_tag" id="tg_{{t.id}}" checked> {{t.tag}} <a class="filteronly">[only]</a> {% endfor %}
+      <input type="checkbox" class="filterall" checked> All
+    </td>
   </tr>
 {%endif%}
   <tr>
     <td>Statuses:</td>
-    <td><select id="selectStatuses" name="statuses" multiple="multiple" style="inline-block; width=80%;">
-{%for sid,s in status_choices %}
-      <option value="{{sid}}"{%if sid in selectedstatuses%} SELECTED{%endif%}>{{s}}</option>
-{%endfor%}
-    </select></td>
+    <td>
+      {% for sid, s in status_choices %}<input type="checkbox" class="filtercheck filtercheck_status" id="st_{{sid}}" checked> {{s}} <a class="filteronly">[only]</a> {% endfor %}
+      <input type="checkbox" class="filterall" checked> All
+    </td>
   </tr>
+{% if isvoter %}
   <tr>
-    <td colspan="2">
-      <input type="checkbox" name="nonvoted" value="1"{%if nonvoted %} CHECKED{%endif%}> Show only sessions you have not voted for
+    <td>Voted:</td>
+    <td>
+      <input type="checkbox" class="filtercheck filtercheck_voted" id="vt_1"> Show only sessions you have not voted for
     </td>
   </tr>
+{% endif %}
   <tr>
     <td colspan="2">
-  <input type="submit" class="btn btn-default" value="Update">
   <a href="." class="btn btn-default">Reset</a>
 {%if isadmin%}
   <a href="../sessionnotifyqueue/" id="pendingNotificationsButton" class="btn btn-warning"{%if not conference.pending_session_notifications%} style="display:none;"{%endif%}>View and send pending notifications</a>
@@ -261,7 +452,7 @@ ul.comments span.username {
     </td>
   </tr>
  </table>
-</form>
+</fieldset>
 
 <div id="ajaxStatus" class="alert alert-success" style="position: fixed; width: 100%;opacity: .8; text-align: center; font-weight: bold; font-size: 1.2em;">Loading submissions</div>
 
@@ -269,70 +460,48 @@ ul.comments span.username {
 <div class="alert alert-warning">There are no talkvoters configured on this conference! List of talks will be empty!</div>
 {%endif%}
 
+<fieldset>
+  <legend>Vote on sessions</legend>
 <p><b>Scoring method:</b> {{ scoring_method }}</p>
-
-<table id="votetable" class="table table-bordered table-condensed" style="display:none">
- <tr>
-  <th style="width: 1%">Seq</th>
-  <th class="col-md-6"><a href="?{{urlfilter}}sort=session">Session</a> | <a href="?{{urlfilter}}sort=speakers">Speakers</a></th>
-  <th>Status</th>
+<p><b>Columns:</b> {% for fc in filtercolumns %}<input type="checkbox" id="col_{{fc.class}}"{%if fc.default%} checked{%endif%}> {{fc.title}} {% endfor %}
+
+<table id="votetable" class="table table-bordered table-condensed">
+ <tbody class="header">
+ <tr class="headerrow tableheader">
+  <th class="flt-seq" style="width: 1%">Seq</th>
+  <th class="flt-id" style="width: 1%"><a class="sortheader sortnumber">Id</a></th>
+  <th class="col-md-6"><a class="sortheader">Session</a></th>
+  <th class="flt-spk"><a class="sortheader">Speakers</a></th>
+  <th class="flt-comp"><a class="sortheader">Companies</a></th>
+  <th class="flt-trk"><a class="sortheader">Track</a></th>
+  <th class="flt-tag"><a class="sortheader">Tags</a></th>
+  <th><a class="sortheader">Status</a></th>
 
   {%for u in users%}
   {% if conference.showvotes or isadmin or u == user.username and isvoter %}
-  <th>{{u}}</th>
+  <th class="flt-votes">{%if u == user.username%}{{u}}{%else%}<a class="sortheader sortnumber">{{u}}{%endif%}</a></th>
   {% endif %}
   {%endfor%}
 
   {% if conference.showvotes or isadmin %}
-  <th><a href="?{{urlfilter}}sort=avg">Average</a></th>
+  <th class="flt-avg"><a class="sortheader sortnumber">Average</a></th>
   {% endif %}
 
-  <th>Comments</th>
+  <th class="flt-cmt">Comments</th>
  </tr>
+ </tbody>
 {%for s in sessionvotes%}
- <tr>
-   <td class="text-center">{{forloop.counter}}</td>
+ <tbody data-sid="{{s.id}}">
+ <tr class="headerrow sessionrow" data-sid="{{s.id}}" data-status="{{s.statusid}}" data-track="{{s.trackid|default:"0"}}"{% if conference.callforpaperstags %} data-tags="{{s.tags|join_dictkeys:"id,,"}}"{% endif %}>
+   <td class="flt-seq text-center">{{forloop.counter}}</td>
+   <td class="flt-id">{{s.id}}</td>
    <td>
-     <div class="talkaccd">
-       <h3>{{s.title}} ({{s.speakerdata|join_dictkeys:"fullname"}}) [id: {{s.id}}]</h3>
-       <div>
-{%if isadmin%}
-        <a class="btn btn-default" style="float:right" href="/events/admin/{{conference.urlname}}/sessions/{{s.id}}/" target="_blank">Edit submission</a>
-{%endif%}
-        <div><strong>Speakers:</strong> {%for sp in s.speakerdata%}{{sp.fullname}}{%if sp.company%} ({{sp.company}}){%endif%}{%if not forloop.last%}, {%endif%}{%endfor%}</div>
-        <div><strong>Track:</strong> {{s.trackname|default:""}}</div>
-{%if conference.callforpaperstags%}
-   <div><strong>Tags:</strong> {%for t in s.tags%}<span class="label label-primary">{{t}}</span> {%endfor%}</div>
-{%endif%}
-{%if conference.callforpapersrecording%}
-        <div><strong>Recording consent:</strong> {{s.recordingconsent | yesno:"Yes,No" }}</div>
-{%endif%}       <br/>
-{{s.abstract|markdown}}
-{%if s.submissionnote%}
-        <hr/>
-        <h3>Submission notes</h3>
-        <p>
-{{s.submissionnote}}
-        </p>
-{%endif%}
-{%if s.internalnote%}
-        <hr/>
-        <h3>Internal notes</h3>
-        <p>
-{{s.internalnote}}
-        </p>
-{%endif%}
-        <hr/>
-{%if s.speakerdata %}
-        <h3>Speaker profile</h3>
-{%for sp in s.speakerdata %}
-        <h4>{{sp.fullname}}{%if sp.company%} ({{sp.company}}){%endif%} [speaker id: {{sp.id}}]</h4>
-          {{sp.abstract|markdown}}
-{%endfor%}
-{%endif%}
-       </div>
-     </div>
+     <h3><label class="dropdown-checkbox"><input type="checkbox" data-sid="{{s.id}}"><span></span></label> {{s.title}}</h3>
    </td>
+   <td class="flt-spk">{{s.speakerdata|join_dictkeys:"fullname"}}</td>
+   <td class="flt-comp">{{s.speakerdata|join_dictkeys:"company"}}</td>
+   <td class="flt-trk">{{s.trackname|default:""}}</td>
+   <td class="flt-tag">{{s.tags|join_dictkeys:"tag"}}</td>
    {%if isadmin%}
    <td{%if s.speakerdata and not s.statusid == s.laststatusid %} bgcolor="yellow"{%endif%} onClick="changeStatus({{s.id}})" class="dlgClickable" id="statusstr{{s.id}}" data-currstatus="{{s.statusid}}"><a href="#" onclick="return false;">{{s.status}}</a></td>
 {%else%}
@@ -341,7 +510,7 @@ ul.comments span.username {
 
   {%for u in users%}
    {%if u == user.username%}
-      <td{%if s.votes|dictlookup:u is None %} style="background-color:red"{%endif%}>
+      <td class="flt-votes" data-voted="{%if s.votes|dictlookup:u is None %}no{%else%}yes{%endif%}">
         <select id="sv_{{s.id}}" onChange="castVote({{s.id}})">
           {%for val, opt in options%}
             <option value="{{val}}"{%if val == s.votes|dictlookup:u|default_if_none:0%} SELECTED{%endif%}>{{opt}}</option>
@@ -350,16 +519,16 @@ ul.comments span.username {
       </td>
     {%else%}
       {% if conference.showvotes or isadmin %}
-        <td>{%if s.votes|dictlookup:u == -1%}Abstain{%else%}{{s.votes|dictlookup:u|default_if_none:''}}{%endif%}</td>
+        <td class="flt-votes">{%if s.votes|dictlookup:u == -1%}Abstain{%else%}{{s.votes|dictlookup:u|default_if_none:''}}{%endif%}</td>
       {%endif%}
     {%endif%}
   {%endfor%}
 
   {% if conference.showvotes or isadmin %}
-  <td class="avgbox">{{s.avg|default_if_none:''}}</td>
+  <td class="avgbox flt-avg">{{s.avg|default_if_none:''}}</td>
   {% endif %}
 
-   <td>
+   <td class="flt-cmt">
 {%if isvoter%}
      <div style="margin-right: 0.5em; float:left;">
        <a class="btn btn-default btn-xs" href="javascript:editComment({{s.id}})"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
@@ -374,9 +543,52 @@ ul.comments span.username {
      </div>
    </td>
  </tr>
+ <tr class="detailsrow" data-sid="{{s.id}}" id="detailsrow_{{s.id}}">
+   <td colspan="{{colcount}}">
+     <div class="detailscontent">
+{%if isadmin%}
+       <a class="btn btn-default" style="float:right" href="/events/admin/{{conference.urlname}}/sessions/{{s.id}}/" target="_blank">Edit submission</a>
+{%endif%}
+       <div><strong>Speakers:</strong> {%for sp in s.speakerdata%}{{sp.fullname}}{%if sp.company%} ({{sp.company}}){%endif%}{%if not forloop.last%}, {%endif%}{%endfor%}</div>
+       <div><strong>Track:</strong> {{s.trackname|default:""}}</div>
+{%if conference.callforpaperstags%}
+       <div><strong>Tags:</strong> {%for t in s.tags%}<span class="label label-primary">{{t.tag}}</span> {%endfor%}</div>
+{%endif%}
+{%if conference.callforpapersrecording%}
+       <div><strong>Recording consent:</strong> {{s.recordingconsent | yesno:"Yes,No" }}</div>
+{%endif%}       <br/>
+{{s.abstract|markdown}}
+{%if s.submissionnote%}
+            <hr/>
+            <h3>Submission notes</h3>
+       <p>
+{{s.submissionnote}}
+            </p>
+{%endif%}
+{%if s.internalnote%}
+            <hr/>
+            <h3>Internal notes</h3>
+       <p>
+{{s.internalnote}}
+            </p>
+{%endif%}
+            <hr/>
+{%if s.speakerdata %}
+            <h3>Speaker profile</h3>
+{%for sp in s.speakerdata %}
+            <h4>{{sp.fullname}}{%if sp.company%} ({{sp.company}}){%endif%} [speaker id: {{sp.id}}]</h4>
+            {{sp.abstract|markdown}}
+{%endfor%}
+{%endif%}
+    </div>
+   </td>
+ </tr>
+</tbody>
 {%endfor%}
 </table>
 
+</fieldset>
+
 <div id="dlgStatus">
 </div>