diff --git a/ietf/meeting/helpers.py b/ietf/meeting/helpers.py index 85c687d42..d0fbbdbfd 100644 --- a/ietf/meeting/helpers.py +++ b/ietf/meeting/helpers.py @@ -166,6 +166,16 @@ def get_schedule_by_name(meeting, owner, name): return meeting.schedule_set.filter(name = name).first() def preprocess_assignments_for_agenda(assignments_queryset, meeting, extra_prefetches=()): + """Add computed properties to assignments + + For each assignment a, adds + a.start_timestamp + a.end_timestamp + a.session.historic_group + a.session.historic_parent + a.session.rescheduled_to (if rescheduled) + a.session.prefetched_active_materials + """ assignments_queryset = assignments_queryset.prefetch_related( 'timeslot', 'timeslot__type', 'timeslot__meeting', 'timeslot__location', 'timeslot__location__floorplan', 'timeslot__location__urlresource_set', @@ -260,9 +270,9 @@ def filter_keywords_for_session(session): if group.state_id == 'bof': keywords.add('bof') keywords.add(group.acronym.lower()) - token = session.docname_token_only_for_multiple() - if token is not None: - keywords.add(group.acronym.lower() + "-" + token) + specific_kw = filter_keyword_for_specific_session(session) + if specific_kw is not None: + keywords.add(specific_kw) area = getattr(group, 'historic_parent', group.parent) # Only sessions belonging to "regular" groups should respond to the @@ -276,6 +286,18 @@ def filter_keywords_for_session(session): keywords.update(['officehours', session.name.lower().replace(' ', '')]) return sorted(list(keywords)) +def filter_keyword_for_specific_session(session): + """Get keyword that identifies a specific session + + Returns None if the session cannot be selected individually. + """ + group = getattr(session, 'historic_group', session.group) + if group is None: + return None + kw = group.acronym.lower() # start with this + token = session.docname_token_only_for_multiple() + return kw if token is None else '{}-{}'.format(kw, token) + def read_session_file(type, num, doc): # XXXX FIXME: the path fragment in the code below should be moved to # settings.py. The *_PATH settings should be generalized to format() diff --git a/ietf/meeting/templatetags/agenda_custom_tags.py b/ietf/meeting/templatetags/agenda_custom_tags.py index b04227f23..30b18e0f3 100644 --- a/ietf/meeting/templatetags/agenda_custom_tags.py +++ b/ietf/meeting/templatetags/agenda_custom_tags.py @@ -3,6 +3,7 @@ from django import template +from django.urls import reverse register = template.Library() @@ -60,3 +61,10 @@ def args(obj, arg): obj.__callArg += [arg] return obj +@register.simple_tag(name='webcal_url', takes_context=True) +def webcal_url(context, viewname, *args, **kwargs): + """webcal URL for a view""" + return 'webcal://{}{}'.format( + context.request.get_host(), + reverse(viewname, args=args, kwargs=kwargs) + ) \ No newline at end of file diff --git a/ietf/meeting/tests_js.py b/ietf/meeting/tests_js.py index f0c616fdb..254db7704 100644 --- a/ietf/meeting/tests_js.py +++ b/ietf/meeting/tests_js.py @@ -1355,7 +1355,65 @@ class AgendaTests(IetfSeleniumTestCase): wait.until(in_iframe_href('tz=america/halifax', 'weekview')) except: self.fail('iframe href not updated to contain selected time zone') - + + def test_agenda_session_selection(self): + wait = WebDriverWait(self.driver, 2) + url = self.absreverse('ietf.meeting.views.agenda_personalize', kwargs={'num': self.meeting.number}) + self.driver.get(url) + + # Verify that elements are all updated when the filters change. That the correct elements + # have the appropriate classes is a separate test. + elements_to_check = self.driver.find_elements_by_css_selector('.agenda-link.filterable') + self.assertGreater(len(elements_to_check), 0, 'No elements with agenda links to update were found') + + self.assertFalse( + any(checkbox.is_selected() + for checkbox in self.driver.find_elements_by_css_selector( + 'input.checkbox[name="selected-sessions"]')), + 'Sessions were selected before being clicked', + ) + + mars_checkbox = self.driver.find_element_by_css_selector('input[type="checkbox"][name="selected-sessions"][data-filter-item="mars"]') + break_checkbox = self.driver.find_element_by_css_selector('input[type="checkbox"][name="selected-sessions"][data-filter-item="secretariat-sessb"]') + registration_checkbox = self.driver.find_element_by_css_selector('input[type="checkbox"][name="selected-sessions"][data-filter-item="secretariat-sessa"]') + secretariat_button = self.driver.find_element_by_css_selector('button[data-filter-item="secretariat"]') + + mars_checkbox.click() # select mars session + try: + wait.until( + lambda driver: all('?show=mars' in el.get_attribute('href') for el in elements_to_check) + ) + except TimeoutException: + self.fail('Some agenda links were not updated when mars session was selected') + self.assertTrue(mars_checkbox.is_selected(), 'mars session checkbox was not selected after being clicked') + self.assertFalse(break_checkbox.is_selected(), 'break checkbox was selected without being clicked') + self.assertFalse(registration_checkbox.is_selected(), 'registration checkbox was selected without being clicked') + + mars_checkbox.click() # deselect mars session + try: + wait.until( + lambda driver: not any('?show=mars' in el.get_attribute('href') for el in elements_to_check) + ) + except TimeoutException: + self.fail('Some agenda links were not updated when mars session was de-selected') + self.assertFalse(mars_checkbox.is_selected(), 'mars session checkbox was still selected after being clicked') + self.assertFalse(break_checkbox.is_selected(), 'break checkbox was selected without being clicked') + self.assertFalse(registration_checkbox.is_selected(), 'registration checkbox was selected without being clicked') + + secretariat_button.click() # turn on all secretariat sessions + break_checkbox.click() # also select the break + + try: + wait.until( + lambda driver: all( + '?show=secretariat&hide=secretariat-sessb' in el.get_attribute('href') + for el in elements_to_check + )) + except TimeoutException: + self.fail('Some agenda links were not updated when secretariat group but not break was selected') + self.assertFalse(mars_checkbox.is_selected(), 'mars session checkbox was unexpectedly selected') + self.assertFalse(break_checkbox.is_selected(), 'break checkbox was unexpectedly selected') + self.assertTrue(registration_checkbox.is_selected(), 'registration checkbox was expected to be selected') @ifSeleniumEnabled class WeekviewTests(IetfSeleniumTestCase): @@ -1693,7 +1751,10 @@ class InterimTests(IetfSeleniumTestCase): self.assert_upcoming_view_filter_matches_ics_filter(querystring) # Check the ical links - simplified_querystring = querystring.replace(' ', '%20') # encode spaces' + simplified_querystring = querystring.replace(' ', '') # remove spaces + if simplified_querystring in ['?show=', '?hide=', '?show=&hide=']: + simplified_querystring = '' # these empty querystrings will be dropped (not an exhaustive list) + ics_link = self.driver.find_element_by_link_text('Download as .ics') self.assertIn(simplified_querystring, ics_link.get_attribute('href')) webcal_link = self.driver.find_element_by_link_text('Subscribe with webcal') diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 85aa9dd31..590149a6c 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -35,6 +35,7 @@ from ietf.meeting.helpers import can_approve_interim_request, can_view_interim_r from ietf.meeting.helpers import send_interim_approval_request from ietf.meeting.helpers import send_interim_meeting_cancellation_notice, send_interim_session_cancellation_notice from ietf.meeting.helpers import send_interim_minutes_reminder, populate_important_dates, update_important_dates +from ietf.meeting.helpers import filter_keyword_for_specific_session from ietf.meeting.models import Session, TimeSlot, Meeting, SchedTimeSessAssignment, Schedule, SessionPresentation, SlideSubmission, SchedulingEvent, Room, Constraint, ConstraintName from ietf.meeting.test_data import make_meeting_test_data, make_interim_meeting, make_interim_test_data from ietf.meeting.utils import finalize, condition_slide_order @@ -375,6 +376,63 @@ class MeetingTests(TestCase): self.assertEqual(r_with_tz.status_code,200) self.assertEqual(r.content, r_with_tz.content) + def test_agenda_personalize(self): + """Session selection page should have a checkbox for each session with appropriate keywords""" + meeting = make_meeting_test_data() + url = urlreverse("ietf.meeting.views.agenda_personalize",kwargs=dict(num=meeting.number)) + r = self.client.get(url) + self.assertEqual(r.status_code,200) + q = PyQuery(r.content) + for assignment in SchedTimeSessAssignment.objects.filter( + schedule__in=[meeting.schedule, meeting.schedule.base], + timeslot__type__private=False, + ): + row = q('#row-{}'.format(assignment.slug())) + self.assertIsNotNone(row, 'No row for assignment {}'.format(assignment)) + checkboxes = row('input[type="checkbox"][name="selected-sessions"]') + self.assertEqual(len(checkboxes), 1, + 'Row for assignment {} does not have a checkbox input'.format(assignment)) + checkbox = checkboxes.eq(0) + self.assertEqual( + checkbox.attr('data-filter-item'), + filter_keyword_for_specific_session(assignment.session), + ) + + def test_agenda_personalize_updates_urls(self): + """The correct URLs should be updated when filter settings change on the personalize agenda view + + Tests that the expected elements have the necessary classes. The actual update of these fields + is tested in the JS tests + """ + meeting = make_meeting_test_data() + url = urlreverse("ietf.meeting.views.agenda_personalize",kwargs=dict(num=meeting.number)) + r = self.client.get(url) + self.assertEqual(r.status_code,200) + q = PyQuery(r.content) + + # Find all the elements expected to be updated + expected_elements = [] + nav_tab_anchors = q('ul.nav.nav-tabs > li > a') + for anchor in nav_tab_anchors.items(): + text = anchor.text().strip() + if text in ['Agenda', 'UTC Agenda', 'Select Sessions']: + expected_elements.append(anchor) + for btn in q('.buttonlist a.btn').items(): + text = btn.text().strip() + if text in ['View customized agenda', 'Download as .ics', 'Subscribe with webcal']: + expected_elements.append(btn) + + # Check that all the expected elements have the correct classes + for elt in expected_elements: + self.assertTrue(elt.has_class('agenda-link')) + self.assertTrue(elt.has_class('filterable')) + + # Finally, check that there are no unexpected elements marked to be updated. + # If there are, they should be added to the test above. + self.assertEqual(len(expected_elements), + len(q('.agenda-link.filterable')), + 'Unexpected elements updated') + @override_settings(MEETING_MATERIALS_SERVE_LOCALLY=False, MEETING_DOC_HREFS = settings.MEETING_DOC_CDN_HREFS) def test_materials_through_cdn(self): meeting = make_meeting_test_data(create_interims=True) diff --git a/ietf/meeting/urls.py b/ietf/meeting/urls.py index d7896d5a0..5d7214ceb 100644 --- a/ietf/meeting/urls.py +++ b/ietf/meeting/urls.py @@ -47,6 +47,7 @@ type_ietf_only_patterns = [ url(r'^agenda/by-type$', views.agenda_by_type), url(r'^agenda/by-type/(?P[a-z]+)$', views.agenda_by_type), url(r'^agenda/by-type/(?P[a-z]+)/ics$', views.agenda_by_type_ics), + url(r'^agenda/personalize', views.agenda_personalize), url(r'^agendas/list$', views.list_schedules), url(r'^agendas/edit$', RedirectView.as_view(pattern_name='ietf.meeting.views.list_schedules', permanent=True)), url(r'^agendas/diff/$', views.diff_schedules), diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 986bbe66d..5ea2f09a1 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -65,7 +65,7 @@ from ietf.meeting.helpers import get_wg_list, find_ads_for_meeting from ietf.meeting.helpers import get_meeting, get_ietf_meeting, get_current_ietf_meeting_num from ietf.meeting.helpers import get_schedule, schedule_permissions, is_regular_agenda_filter_group from ietf.meeting.helpers import preprocess_assignments_for_agenda, read_agenda_file -from ietf.meeting.helpers import filter_keywords_for_session, tag_assignments_with_filter_keywords +from ietf.meeting.helpers import filter_keywords_for_session, tag_assignments_with_filter_keywords, filter_keyword_for_specific_session from ietf.meeting.helpers import convert_draft_to_pdf, get_earliest_session_date from ietf.meeting.helpers import can_view_interim_request, can_approve_interim_request from ietf.meeting.helpers import can_edit_interim_request @@ -1464,6 +1464,119 @@ def session_materials(request, session_id): assignment = assignments[0] return render(request, 'meeting/session_materials.html', dict(item=assignment)) + +def get_assignments_for_agenda(schedule): + """Get queryset containing assignments to show on the agenda""" + return SchedTimeSessAssignment.objects.filter( + schedule__in=[schedule, schedule.base], + timeslot__type__private=False, + ) + + +def extract_groups_hierarchy(prepped_assignments): + """Extract groups hierarchy for agenda display + + It's a little bit complicated because we can be dealing with historic groups. + """ + seen = set() + groups = [a.session.historic_group for a in prepped_assignments + if a.session + and a.session.historic_group + and is_regular_agenda_filter_group(a.session.historic_group) + and a.session.historic_group.historic_parent] + group_parents = [] + for g in groups: + if g.historic_parent.acronym not in seen: + group_parents.append(g.historic_parent) + seen.add(g.historic_parent.acronym) + + seen = set() + for p in group_parents: + p.group_list = [] + for g in groups: + if g.acronym not in seen and g.historic_parent.acronym == p.acronym: + p.group_list.append(g) + seen.add(g.acronym) + + p.group_list.sort(key=lambda g: g.acronym) + return group_parents + + +def prepare_filter_keywords(tagged_assignments, group_parents): + # + # The agenda_filter template expects a list of categorized header buttons, each + # with a list of children. Make two categories: the IETF areas and the other parent groups. + # We also pass a list of 'extra' buttons - currently Office Hours and miscellaneous filters. + # All but the last of these are additionally used by the agenda.html template to make + # a list of filtered ical buttons. The last group is ignored for this. + area_group_filters = [] + other_group_filters = [] + extra_filters = [] + + for p in group_parents: + new_filter = dict( + label=p.acronym.upper(), + keyword=p.acronym.lower(), + children=[ + dict( + label=g.acronym, + keyword=g.acronym.lower(), + is_bof=g.is_bof(), + ) for g in p.group_list + ] + ) + if p.type.slug == 'area': + area_group_filters.append(new_filter) + else: + other_group_filters.append(new_filter) + + office_hours_labels = set() + for a in tagged_assignments: + suffix = ' office hours' + if a.session.name.lower().endswith(suffix): + office_hours_labels.add(a.session.name[:-len(suffix)].strip()) + + if len(office_hours_labels) > 0: + # keyword needs to match what's tagged in filter_keywords_for_session() + extra_filters.append(dict( + label='Office Hours', + keyword='officehours', + children=[ + dict( + label=label, + keyword=label.lower().replace(' ', '')+'officehours', + is_bof=False, + ) for label in office_hours_labels + ] + )) + + # Keywords that should appear in 'non-area' column + non_area_labels = [ + 'BoF', 'EDU', 'Hackathon', 'IEPG', 'IESG', 'IETF', 'Plenary', 'Secretariat', 'Tools', + ] + # Remove any unused non-area keywords + non_area_filters = [ + dict(label=label, keyword=label.lower(), is_bof=False) + for label in non_area_labels if any([ + label.lower() in assignment.filter_keywords + for assignment in tagged_assignments + ]) + ] + if len(non_area_filters) > 0: + extra_filters.append(dict( + label=None, + keyword=None, + children=non_area_filters, + )) + + area_group_filters.sort(key=lambda p:p['label']) + other_group_filters.sort(key=lambda p:p['label']) + filter_categories = [category + for category in [area_group_filters, other_group_filters, extra_filters] + if len(category) > 0] + return filter_categories, non_area_labels + + @ensure_csrf_cookie def agenda(request, num=None, name=None, base=None, ext=None, owner=None, utc=""): base = base if base else 'agenda' @@ -1486,6 +1599,7 @@ def agenda(request, num=None, name=None, base=None, ext=None, owner=None, utc="" else: raise Http404("No such meeting") + # Select the schedule to show if name is None: schedule = get_schedule(meeting, name) else: @@ -1497,112 +1611,23 @@ def agenda(request, num=None, name=None, base=None, ext=None, owner=None, utc="" return render(request, "meeting/no-"+base+ext, {'meeting':meeting }, content_type=mimetype[ext]) updated = meeting.updated() - filtered_assignments = SchedTimeSessAssignment.objects.filter( - schedule__in=[schedule, schedule.base], - timeslot__type__private=False, + + # Select and prepare sessions that should be included + filtered_assignments = preprocess_assignments_for_agenda( + get_assignments_for_agenda(schedule), + meeting ) - filtered_assignments = preprocess_assignments_for_agenda(filtered_assignments, meeting) tag_assignments_with_filter_keywords(filtered_assignments) + # Done processing for CSV output if ext == ".csv": return agenda_csv(schedule, filtered_assignments) - # extract groups hierarchy, it's a little bit complicated because - # we can be dealing with historic groups - seen = set() - groups = [a.session.historic_group for a in filtered_assignments - if a.session - and a.session.historic_group - and is_regular_agenda_filter_group(a.session.historic_group) - and a.session.historic_group.historic_parent] - group_parents = [] - for g in groups: - if g.historic_parent.acronym not in seen: - group_parents.append(g.historic_parent) - seen.add(g.historic_parent.acronym) - - seen = set() - for p in group_parents: - p.group_list = [] - for g in groups: - if g.acronym not in seen and g.historic_parent.acronym == p.acronym: - p.group_list.append(g) - seen.add(g.acronym) - - p.group_list.sort(key=lambda g: g.acronym) - - # Groups gathered and processed. Now arrange for the filter UI. - # - # The agenda_filter template expects a list of categorized header buttons, each - # with a list of children. Make two categories: the IETF areas and the other parent groups. - # We also pass a list of 'extra' buttons - currently Office Hours and miscellaneous filters. - # All but the last of these are additionally used by the agenda.html template to make - # a list of filtered ical buttons. The last group is ignored for this. - area_group_filters = [] - other_group_filters = [] - extra_filters = [] - - for p in group_parents: - new_filter = dict( - label=p.acronym.upper(), - keyword=p.acronym.lower(), - children=[ - dict( - label=g.acronym, - keyword=g.acronym.lower(), - is_bof=g.is_bof(), - ) for g in p.group_list - ] - ) - if p.type.slug == 'area': - area_group_filters.append(new_filter) - else: - other_group_filters.append(new_filter) - - office_hours_labels = set() - for a in filtered_assignments: - suffix = ' office hours' - if a.session.name.lower().endswith(suffix): - office_hours_labels.add(a.session.name[:-len(suffix)].strip()) - - if len(office_hours_labels) > 0: - # keyword needs to match what's tagged in filter_keywords_for_session() - extra_filters.append(dict( - label='Office Hours', - keyword='officehours', - children=[ - dict( - label=label, - keyword=label.lower().replace(' ', '')+'officehours', - is_bof=False, - ) for label in office_hours_labels - ] - )) - - # Keywords that should appear in 'non-area' column - non_area_labels = [ - 'BoF', 'EDU', 'Hackathon', 'IEPG', 'IESG', 'IETF', 'Plenary', 'Secretariat', 'Tools', - ] - # Remove any unused non-area keywords - non_area_filters = [ - dict(label=label, keyword=label.lower(), is_bof=False) - for label in non_area_labels if any([ - label.lower() in assignment.filter_keywords - for assignment in filtered_assignments - ]) - ] - if len(non_area_filters) > 0: - extra_filters.append(dict( - label=None, - keyword=None, - children=non_area_filters, - )) - - area_group_filters.sort(key=lambda p:p['label']) - other_group_filters.sort(key=lambda p:p['label']) - filter_categories = [category - for category in [area_group_filters, other_group_filters, extra_filters] - if len(category) > 0] + # Now prep the filter UI + filter_categories, non_area_labels = prepare_filter_keywords( + filtered_assignments, + extract_groups_hierarchy(filtered_assignments), + ) is_current_meeting = (num is None) or (num == get_current_ietf_meeting_num()) @@ -1756,6 +1781,45 @@ def agenda_by_type_ics(request,num=None,type=None): updated = meeting.updated() return render(request,"meeting/agenda.ics",{"schedule":schedule,"updated":updated,"assignments":assignments},content_type="text/calendar") + +def agenda_personalize(request, num): + meeting = get_ietf_meeting(num) # num may be None, which requests the current meeting + if meeting is None or meeting.schedule is None: + raise Http404('No such meeting') + + # Select and prepare sessions that should be included + filtered_assignments = preprocess_assignments_for_agenda( + get_assignments_for_agenda(meeting.schedule), + meeting + ) + tag_assignments_with_filter_keywords(filtered_assignments) + for assignment in filtered_assignments: + # may be None for some sessions + assignment.session_keyword = filter_keyword_for_specific_session(assignment.session) + + # Now prep the filter UI + filter_categories, non_area_labels = prepare_filter_keywords( + filtered_assignments, + extract_groups_hierarchy(filtered_assignments), + ) + + is_current_meeting = (num is None) or (num == get_current_ietf_meeting_num()) + + return render( + request, + "meeting/agenda_personalize.html", + { + 'schedule': meeting.schedule, + 'updated': meeting.updated(), + 'filtered_assignments': filtered_assignments, + 'filter_categories': filter_categories, + 'non_area_labels': non_area_labels, + 'timezone': meeting.time_zone, + 'is_current_meeting': is_current_meeting, + 'cache_time': 150 if is_current_meeting else 3600, + } + ) + def session_draft_list(num, acronym): try: agendas = Document.objects.filter(type="agenda", diff --git a/ietf/static/ietf/js/agenda/agenda_filter.js b/ietf/static/ietf/js/agenda/agenda_filter.js index 58799390b..69d058a63 100644 --- a/ietf/static/ietf/js/agenda/agenda_filter.js +++ b/ietf/static/ietf/js/agenda/agenda_filter.js @@ -5,7 +5,14 @@ var agenda_filter_for_testing; // methods to be accessed for automated testing (function () { 'use strict' - var update_callback; // function(filter_params) + /* n.b., const refers to the opts object itself, not its contents. + * Use camelCase for easy translation into element.dataset keys, + * which are automatically camel-cased from the data attribute name. + * (e.g., data-always-show -> elt.dataset.alwaysShow) */ + const opts = { + alwaysShow: false, + updateCallback: null // function(filter_params) + }; /* Remove from list, if present */ function remove_list_item (list, item) { @@ -58,7 +65,7 @@ var agenda_filter_for_testing; // methods to be accessed for automated testing } function get_filter_params (qparams) { - var enabled = !!(qparams.show || qparams.hide); + var enabled = opts.alwaysShow || qparams.show || qparams.hide; return { enabled: enabled, show: get_filter_from_qparams(qparams, 'show'), @@ -114,8 +121,13 @@ var agenda_filter_for_testing; // methods to be accessed for automated testing return; } + update_href_querystrings(filter_params_as_querystring(filter_params)) + // show the customizer - it will stay visible even if filtering is disabled - $('#customize').collapse('show') + const customizer = $('#customize'); + if (customizer.hasClass('collapse')) { + customizer.collapse('show') + } // Update button state to match visibility buttons.each(function (index, elt) { @@ -140,8 +152,8 @@ var agenda_filter_for_testing; // methods to be accessed for automated testing function update_view () { var filter_params = get_filter_params(parse_query_params(window.location.search)) update_filter_ui(filter_params) - if (update_callback) { - update_callback(filter_params) + if (opts.updateCallback) { + opts.updateCallback(filter_params) } } @@ -151,20 +163,11 @@ var agenda_filter_for_testing; // methods to be accessed for automated testing * (if supported) or loads the new URL. */ function update_filters (filter_params) { - var qparams = [] - var search = '' - if (filter_params.show.length > 0) { - qparams.push('show=' + filter_params.show.join()) - } - if (filter_params.hide.length > 0) { - qparams.push('hide=' + filter_params.hide.join()) - } - if (qparams.length > 0) { - search = '?' + qparams.join('&') - } - - // strip out the search / hash, then add back - var new_url = window.location.href.replace(/(\?.*)?(#.*)?$/, search + window.location.hash) + var new_url = replace_querystring( + window.location.href, + filter_params_as_querystring(filter_params) + ) + update_href_querystrings(filter_params_as_querystring(filter_params)) if (window.history && window.history.replaceState) { // Keep current origin, replace search string, no page reload history.replaceState({}, document.title, new_url) @@ -175,6 +178,35 @@ var agenda_filter_for_testing; // methods to be accessed for automated testing } } + /** + * Update the querystring in the href filterable agenda links + */ + function update_href_querystrings(querystring) { + Array.from( + document.getElementsByClassName('agenda-link filterable') + ).forEach( + (elt) => elt.href = replace_querystring(elt.href, querystring) + ) + } + + function filter_params_as_querystring(filter_params) { + var qparams = [] + if (filter_params.show.length > 0) { + qparams.push('show=' + filter_params.show.join()) + } + if (filter_params.hide.length > 0) { + qparams.push('hide=' + filter_params.hide.join()) + } + if (qparams.length > 0) { + return '?' + qparams.join('&') + } + return '' + } + + function replace_querystring(url, new_querystring) { + return url.replace(/(\?.*)?(#.*)?$/, new_querystring + window.location.hash) + } + /* Helper for pick group/type button handlers - toggles the appropriate parameter entry * elt - the jquery element that was clicked */ @@ -225,7 +257,19 @@ var agenda_filter_for_testing; // methods to be accessed for automated testing update_filters(fp); }); } - + + /** + * Read options from the template + */ + function read_template_options() { + const opts_elt = document.getElementById('agenda-filter-options'); + opts.keys().forEach((opt) => { + if (opt in opts_elt.dataset) { + opts[opt] = opts_elt.dataset[opt]; + } + }); + } + /* Entry point to filtering code when page loads * * This must be called if you are using the HTML template to provide a customization @@ -261,6 +305,6 @@ var agenda_filter_for_testing; // methods to be accessed for automated testing keyword_match: keyword_match, parse_query_params: parse_query_params, rows_matching_filter_keyword: rows_matching_filter_keyword, - set_update_callback: function (cb) {update_callback = cb} + set_update_callback: function (cb) {opts.updateCallback = cb} }; })(); \ No newline at end of file diff --git a/ietf/static/ietf/js/agenda/agenda_personalize.js b/ietf/static/ietf/js/agenda/agenda_personalize.js new file mode 100644 index 000000000..619299e4c --- /dev/null +++ b/ietf/static/ietf/js/agenda/agenda_personalize.js @@ -0,0 +1,82 @@ +// Copyright The IETF Trust 2021, All Rights Reserved + +/** + * Agenda personalization JS methods + * + * Requires agenda_timezone.js and timezone.js be included. + */ +const agenda_personalize = ( + function () { + 'use strict'; + + let meeting_timezone = document.getElementById('initial-data').dataset.timezone; + let selection_inputs; + + /** + * Update the checkbox state to match the filter parameters + */ + function updateAgendaCheckboxes(filter_params) { + selection_inputs.forEach((inp) => { + const item_keywords = inp.dataset.filterKeywords.toLowerCase().split(','); + if ( + agenda_filter.keyword_match(item_keywords, filter_params.show) + && !agenda_filter.keyword_match(item_keywords, filter_params.hide) + ) { + inp.checked = true; + } else { + inp.checked = false; + } + }); + } + + function handleFilterParamUpdate(filter_params) { + updateAgendaCheckboxes(filter_params); + } + + function handleTableClick(event) { + if (event.target.name === 'selected-sessions') { + // hide the tooltip after clicking on a checkbox + const jqElt = jQuery(event.target); + if (jqElt.tooltip) { + jqElt.tooltip('hide'); + } + } + } + + window.addEventListener('load', function () { + // Methods/variables here that are not in ietf_timezone or agenda_filter are from agenda_timezone.js + + // First, initialize_moments(). This must be done before calling any of the update methods. + // It does not need timezone info, so safe to call before initializing ietf_timezone. + initialize_moments(); // fills in moments in the agenda data + + // Now set up callbacks related to ietf_timezone. This must happen before calling initialize(). + // In particular, set_current_tz_cb() must be called before the update methods are called. + set_current_tz_cb(ietf_timezone.get_current_tz); // give agenda_timezone access to this method + ietf_timezone.set_tz_change_callback(function (newtz) { + update_times(newtz); + } + ); + + // With callbacks in place, call ietf_timezone.initialize(). This will call the tz_change callback + // after setting things up. + ietf_timezone.initialize(meeting_timezone); + + // Now make other setup calls from agenda_timezone.js + add_tooltips(); + init_timers(); + + selection_inputs = document.getElementsByName('selected-sessions'); + + agenda_filter.set_update_callback(handleFilterParamUpdate); + agenda_filter.enable(); + + document.getElementById('agenda-table') + .addEventListener('click', handleTableClick); + } + ); + + // export public interface + return { meeting_timezone }; + } +)(); \ No newline at end of file diff --git a/ietf/templates/meeting/agenda.html b/ietf/templates/meeting/agenda.html index 95d93c3d0..70f6fdbd8 100644 --- a/ietf/templates/meeting/agenda.html +++ b/ietf/templates/meeting/agenda.html @@ -116,7 +116,7 @@ {% endfor %}
Non-area events - +

@@ -421,14 +421,7 @@ function update_ical_links(filter_params) { var ical_link = $("#ical-link"); - if (agenda_filter.filtering_is_enabled(filter_params)) { - // Replace the query string in the ical link - var orig_link_href = ical_link.attr("href").split("?")[0]; - ical_link.attr("href", orig_link_href+window.location.search); - ical_link.removeClass("hidden"); - } else { - ical_link.addClass("hidden"); - } + ical_link.toggleClass("hidden", !agenda_filter.filtering_is_enabled(filter_params)); } function update_weekview(filter_params) { diff --git a/ietf/templates/meeting/agenda_filter.html b/ietf/templates/meeting/agenda_filter.html index b3a9222c2..cd91e7c1f 100644 --- a/ietf/templates/meeting/agenda_filter.html +++ b/ietf/templates/meeting/agenda_filter.html @@ -1,14 +1,28 @@ +{% comment %} +Required parameters: + filter_categories - filter description structure (see agenda view for example) + +Optional parameters: + always_show - if False or absent, menu closes when not in use and "Customize" button is shown + customize_button_text - text to show on the "Customize" button (defaults to "Customize...") +{% endcomment %} {% load agenda_filter_tags %}
-
+ {% if not always_show %} + -
+ {% endif %} +
+

diff --git a/ietf/templates/meeting/agenda_personalize.html b/ietf/templates/meeting/agenda_personalize.html new file mode 100644 index 000000000..bee86b1d6 --- /dev/null +++ b/ietf/templates/meeting/agenda_personalize.html @@ -0,0 +1,405 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2021, All Rights Reserved #} +{% load origin %} +{% load static %} +{% load ietf_filters %} +{% load textfilters %} +{% load htmlfilters %} + +{% block title %} + IETF {{ schedule.meeting.number }} meeting agenda personalization +{% endblock %} + +{% block morecss %} + tr:not(:first-child) th.gap { + height: 3em !important; + background-color: inherit !important; + border: none !important; + } + tr:first-child th.gap { + height: 0 !important; + background-color: inherit !important; + border: none !important; + } + div.tz-display { + margin-bottom: 0.5em; + margin-top: 1em; + text-align: right; + } + .tz-display a { + cursor: pointer; + } + .tz-display label { + font-weight: normal; + } + .tz-display select { + min-width: 15em; + } + #affix .nav li.tz-display { + padding: 4px 20px; + } + #affix .nav li.tz-display a { + display: inline; + padding: 0; + } +{% endblock %} + +{% block bodyAttrs %}data-spy="scroll" data-target="#affix"{% endblock %} + +{% block content %} + {% origin %} + +

+
+ {% include "meeting/meeting_heading.html" with meeting=schedule.meeting updated=updated selected="select-sessions" title_extra="" %} +
+
+
+
+ {# cache this part -- it takes 3-6 seconds to generate #} + {% load cache %} + {% cache cache_time ietf_meeting_agenda_personalize schedule.meeting.number request.path %} +
+

Session Selection

+
+
+
+ + Meeting | + Local | + UTC +
+ +
+
+
+ {% if is_current_meeting %} +

+ Note: IETF agendas are subject to change, up to and during a meeting. +

+ {% endif %} + + {% include "meeting/agenda_filter.html" with filter_categories=filter_categories always_show=True %} + + {% include "meeting/agenda_personalize_buttonlist.html" with meeting=schedule.meeting only %} + +

+ Individual Sessions +

+

+ Check boxes below to select individual sessions. +

+ + + {% for item in filtered_assignments %} + + {% ifchanged item.timeslot.time|date:"Y-m-d" %} + + + + + + + {% endifchanged %} + + {% if item.timeslot.type_id == 'regular' %} + {% ifchanged %} + + + + + + + {% endifchanged %} + {% endif %} + + + {% if item.timeslot.type.slug == 'break' or item.timeslot.type.slug == 'reg' or item.timeslot.type.slug == 'other' %} + + + + + + + + {% endif %} + + {% if item.timeslot.type_id == 'regular' or item.timeslot.type.slug == 'plenary' %} + {% if item.session.historic_group %} + + + {% if item.timeslot.type.slug == 'plenary' %} + + + + {% else %} + + + + + + + {% endif %} + + + + + {% endif %} + {% endif %} + {% endfor %} +
+ {# The anchor here needs to be in a div, not in the th, in order for the anchor-target margin hack to work #} +
+ {{ item.timeslot.time|date:"l, F j, Y" }} +
+ + + + {{ item.timeslot.time|date:"l" }} + {{ item.timeslot.name|capfirst_allcaps }} +
+ {% if item.session_keyword %} + + {% endif %} + + + + + {% if item.timeslot.show_location and item.timeslot.get_html_location %} + {% if schedule.meeting.number|add:"0" < 96 %} + {{ item.timeslot.get_html_location }} + {% elif item.timeslot.location.floorplan %} + {{ item.timeslot.get_html_location }} + {% else %} + {{ item.timeslot.get_html_location }} + {% endif %} + {% with item.timeslot.location.floorplan as floor %} + {% if item.timeslot.location.floorplan %} + + {% endif %} + {% endwith %} + {% endif %} + + {% if item.session.agenda %} + + {{ item.timeslot.name }} + + {% else %} + {{ item.timeslot.name }} + {% endif %} + + {% if item.session.current_status == 'canceled' %} + CANCELLED + {% endif %} +
+ {% if item.session_keyword %} + + {% endif %} + + + + + {% if item.timeslot.show_location and item.timeslot.get_html_location %} + {% if schedule.meeting.number|add:"0" < 96 %} + {{ item.timeslot.get_html_location }} + {% elif item.timeslot.location.floorplan %} + {{ item.timeslot.get_html_location }} + {% else %} + {{ item.timeslot.get_html_location }} + {% endif %} + {% endif %} + + {% with item.timeslot.location.floorplan as floor %} + {% if item.timeslot.location.floorplan %} + + {% endif %} + {% endwith %} + + {% if item.timeslot.show_location and item.timeslot.get_html_location %} + {% if schedule.meeting.number|add:"0" < 96 %} + {{ item.timeslot.get_html_location }} + {% elif item.timeslot.location.floorplan %} + {{ item.timeslot.get_html_location }} + {% else %} + {{ item.timeslot.get_html_location }} + {% endif %} + {% endif %} + + {% if item.session.historic_group %} + {{ item.session.historic_group.acronym }} + {% else %} + {{ item.session.historic_group.acronym }} + {% endif %} + + {% if item.session.agenda %} + + {% endif %} + {% if item.timeslot.type.slug == 'plenary' %} + {{ item.timeslot.name }} + {% else %} + {{ item.session.historic_group.name }} + {% endif %} + {% if item.session.agenda %} + + {% endif %} + + {% if item.session.current_status == 'canceled' %} + CANCELLED + {% endif %} + + {% if item.session.historic_group.state_id == "bof" %} + BOF + {% endif %} + + {% if item.session.current_status == 'resched' %} + + RESCHEDULED + {% if item.session.rescheduled_to %} + TO + + {% if "-utc" in request.path %} + {{ item.session.rescheduled_to.utc_start_time|date:"l G:i"|upper }}- + {{ item.session.rescheduled_to.utc_end_time|date:"G:i" }} + {% else %} + {{ item.session.rescheduled_to.time|date:"l G:i"|upper }}- + {{ item.session.rescheduled_to.end_time|date:"G:i" }} + {% endif %} + + {% endif %} + + {% endif %} + + {% if item.session.agenda_note|first_url|conference_url %} +
+ {{ item.session.agenda_note|slice:":23" }} + {% elif item.session.agenda_note %} +
{{ item.session.agenda_note }} + {% endif %} +
+ + {% include "meeting/agenda_personalize_buttonlist.html" with meeting=schedule.meeting only %} + +
+
+ +
+
+ + {% endcache %} + + {# make the timezone available to JS #} + +{% endblock %} + +{% block js %} + + + + + + + +{% endblock %} diff --git a/ietf/templates/meeting/agenda_personalize_buttonlist.html b/ietf/templates/meeting/agenda_personalize_buttonlist.html new file mode 100644 index 000000000..de23d5f64 --- /dev/null +++ b/ietf/templates/meeting/agenda_personalize_buttonlist.html @@ -0,0 +1,20 @@ +{% comment %} +Buttons for the agenda_personalize.html template + +Required parameter: meeting - meeting being displayed +{% endcomment %} +{% load agenda_custom_tags %} + diff --git a/ietf/templates/meeting/meeting_heading.html b/ietf/templates/meeting/meeting_heading.html index 0a5c371d3..23726d1db 100644 --- a/ietf/templates/meeting/meeting_heading.html +++ b/ietf/templates/meeting/meeting_heading.html @@ -28,30 +28,57 @@ + {# tags with the agenda-link filterable classes will be updated with show/hide parameters #} diff --git a/ietf/templates/meeting/upcoming.html b/ietf/templates/meeting/upcoming.html index d3b3cba9f..77bc013e7 100644 --- a/ietf/templates/meeting/upcoming.html +++ b/ietf/templates/meeting/upcoming.html @@ -83,8 +83,7 @@ {% if menu_actions %} @@ -314,24 +313,8 @@ }); } - function update_links(filter_params) { - var filtered_links = $("#menu-actions [data-append-filter='True']"); - var filtering_enabled = agenda_filter.filtering_is_enabled(filter_params); - filtered_links.each(function(index, elt) { - var orig_link_href = $(elt).attr("href").split("?")[0]; - if (filtering_enabled) { - // append new querystring - $(elt).attr("href", orig_link_href+window.location.search); - } else { - // remove querystring - $(elt).attr("href", orig_link_href); - } - }); - } - function update_view(filter_params) { update_meeting_display(filter_params); - update_links(filter_params); update_calendar(current_tz, filter_params); }