diff --git a/ietf/static/js/agenda_filter.js b/ietf/static/js/agenda_filter.js new file mode 100644 index 000000000..92da3f600 --- /dev/null +++ b/ietf/static/js/agenda_filter.js @@ -0,0 +1,310 @@ +window.agenda_filter; // public interface +window.agenda_filter_for_testing; // methods to be accessed for automated testing + +// closure to create private scope +(function () { + 'use strict' + + /* 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) { + var item_index = list.indexOf(item); + if (item_index !== -1) { + list.splice(item_index, 1) + } + } + + /* Add to list if not present, remove if present + * + * Returns true if added to the list, otherwise false. + */ + function toggle_list_item (list, item) { + var item_index = list.indexOf(item); + if (item_index === -1) { + list.push(item) + return true; + } else { + list.splice(item_index, 1) + return false; + } + } + + function parse_query_params (qs) { + var params = {} + qs = decodeURI(qs).replace(/^\?/, '').toLowerCase() + if (qs) { + var param_strs = qs.split('&') + for (var ii = 0; ii < param_strs.length; ii++) { + var toks = param_strs[ii].split('=', 2) + params[toks[0]] = toks[1] || true + } + } + return params + } + + /* filt = 'show' or 'hide' */ + function get_filter_from_qparams (qparams, filt) { + if (!qparams[filt] || (qparams[filt] === true)) { + return []; + } + var result = []; + var qp = qparams[filt].split(','); + + for (var ii = 0; ii < qp.length; ii++) { + result.push(qp[ii].trim()); + } + return result; + } + + function get_filter_params (qparams) { + var enabled = opts.alwaysShow || qparams.show || qparams.hide; + return { + enabled: enabled, + show: get_filter_from_qparams(qparams, 'show'), + hide: get_filter_from_qparams(qparams, 'hide') + } + } + + function get_keywords(elt) { + var keywords = $(elt).attr('data-filter-keywords'); + if (keywords) { + return keywords.toLowerCase().split(','); + } + return []; + } + + function get_item(elt) { + return $(elt).attr('data-filter-item'); + } + + // utility method - is there a match between two lists of keywords? + function keyword_match(list1, list2) { + for (var ii = 0; ii < list1.length; ii++) { + if (list2.indexOf(list1[ii]) !== -1) { + return true; + } + } + return false; + } + + // Find the items corresponding to a keyword + function get_items_with_keyword (keyword) { + var items = []; + + $('.view button.pickview').filter(function(index, elt) { + return keyword_match(get_keywords(elt), [keyword]); + }).each(function (index, elt) { + items.push(get_item($(elt))); + }); + return items; + } + + function filtering_is_enabled (filter_params) { + return filter_params.enabled; + } + + // Update the filter / customization UI to match the current filter parameters + function update_filter_ui (filter_params) { + var buttons = $('.pickview'); + + if (!filtering_is_enabled(filter_params)) { + // Not filtering - set to default and exit + buttons.removeClass('active'); + return; + } + + update_href_querystrings(filter_params_as_querystring(filter_params)) + + // show the customizer - it will stay visible even if filtering is disabled + const customizer = $('#customize'); + if (customizer.hasClass('collapse')) { + customizer.collapse('show') + } + + // Update button state to match visibility + buttons.each(function (index, elt) { + elt = $(elt); + var keywords = get_keywords(elt); + keywords.push(get_item(elt)); // treat item as one of its keywords + var hidden = keyword_match(filter_params.hide, keywords); + var shown = keyword_match(filter_params.show, keywords); + if (shown && !hidden) { + elt.addClass('active'); + } else { + elt.removeClass('active'); + } + }); + } + + /* Update state of the view to match the filters + * + * Calling the individual update_* functions outside of this method will likely cause + * various parts of the page to get out of sync. + */ + function update_view () { + var filter_params = get_filter_params(parse_query_params(window.location.search)) + update_filter_ui(filter_params) + if (opts.updateCallback) { + opts.updateCallback(filter_params) + } + } + + /* Trigger an update so the user will see the page appropriate for given filter_params + * + * Updates the URL to match filter_params, then updates the history / display to match + * (if supported) or loads the new URL. + */ + function update_filters (filter_params) { + 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) + update_view() + } else { + // No window.history.replaceState support, page reload required + window.location = new_url + } + } + + /** + * 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 + */ + function handle_pick_button (elt) { + var fp = get_filter_params(parse_query_params(window.location.search)); + var item = get_item(elt); + + /* Normally toggle in and out of the 'show' list. If this item is active because + * one of its keywords is active, invert the sense and toggle in and out of the + * 'hide' list instead. */ + var inverted = keyword_match(fp.show, get_keywords(elt)); + var just_showed_item = false; + if (inverted) { + toggle_list_item(fp.hide, item); + remove_list_item(fp.show, item); + } else { + just_showed_item = toggle_list_item(fp.show, item); + remove_list_item(fp.hide, item); + } + + /* If we just showed an item, remove its children from the + * show/hide lists to keep things consistent. This way, selecting + * an area will enable all items in the row as one would expect. */ + if (just_showed_item) { + var children = get_items_with_keyword(item); + $.each(children, function(index, child) { + remove_list_item(fp.show, child); + remove_list_item(fp.hide, child); + }); + } + + // If the show list is empty, clear the hide list because there is nothing to hide + if (fp.show.length === 0) { + fp.hide = []; + } + + return fp; + } + + function is_disabled(elt) { + return elt.hasClass('disabled'); + } + + function register_handlers() { + $('.pickview').on("click", function () { + if (is_disabled($(this))) { return; } + var fp = handle_pick_button($(this)); + 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 + * button UI. Do not call if you only want to use the parameter parsing routines. + */ + function enable () { + // ready handler fires immediately if document is already "ready" + $(document).ready(function () { + register_handlers(); + update_view(); + }) + } + + // utility method - filter a jquery set to those matching a keyword + function rows_matching_filter_keyword(rows, kw) { + return rows.filter(function(index, element) { + var row_kws = get_keywords(element); + return keyword_match(row_kws, [kw.toLowerCase()]); + }); + } + + // Make private functions available for unit testing + agenda_filter_for_testing = { + parse_query_params: parse_query_params, + toggle_list_item: toggle_list_item + }; + + // Make public interface methods accessible + agenda_filter = { + enable: enable, + filtering_is_enabled: filtering_is_enabled, + get_filter_params: get_filter_params, + keyword_match: keyword_match, + parse_query_params: parse_query_params, + rows_matching_filter_keyword: rows_matching_filter_keyword, + set_update_callback: function (cb) {opts.updateCallback = cb} + }; +})(); \ No newline at end of file diff --git a/ietf/static/js/agenda_materials.js b/ietf/static/js/agenda_materials.js new file mode 100644 index 000000000..c5db2fd4d --- /dev/null +++ b/ietf/static/js/agenda_materials.js @@ -0,0 +1,84 @@ +// Copyright The IETF Trust 2021, All Rights Reserved + +/* + Javascript support for the materials modal rendered by session_agenda_include.html + + Requires jquery be loaded + */ + +var agenda_materials; // public interface + +(function() { + 'use strict'; + /** + * Retrieve and display materials for a session + * + * If output_elt exists and has a "data-src" attribute, retrieves the document + * from that URL and displays under output_elt. Handles text/plain, text/markdown, + * and text/html. + * + * @param output_elt Element, probably a div, to hold the output + */ + function retrieve_session_materials(output_elt) { + if (!output_elt) {return;} + output_elt = $(output_elt); + var data_src = output_elt.attr("data-src"); + if (!data_src) { + output_elt.html("
Error: missing data-src attribute
"); + } else { + output_elt.html("Loading " + data_src + "...
"); + var outer_xhr = $.ajax({url:data_src,headers:{'Accept':'text/plain;q=0.8,text/html;q=0.9'}}) + outer_xhr.done(function(data, status, xhr) { + var t = xhr.getResponseHeader("content-type"); + if (!t) { + data = "Error retrieving " + data_src + + ": Missing content-type in response header
"; + } else if (t.indexOf("text/plain") > -1) { + data = "" + data + ""; + } else if (t.indexOf("text/markdown") > -1) { + data = "
" + data + ""; + } else if(t.indexOf("text/html") > -1) { + // nothing to do here + } else { + data = "
Unknown type: " + xhr.getResponseHeader("content-type") + "
"; + } + output_elt.html(data); + }).fail(function() { + output_elt.html("Error retrieving " + data_src + + ": (" + outer_xhr.status.toString() + ") " + + outer_xhr.statusText + "
"); + }) + } + } + + /** + * Retrieve contents of a session materials modal + * + * Expects output_elt to exist and have a "data-src" attribute. Retrieves the + * contents of that URL, then attempts to populate the .agenda-frame and + * .minutes-frame elements. + * + * @param output_elt Element, probably a div, to hold the output + */ + function retrieve_session_modal(output_elt) { + if (!output_elt) {return;} + output_elt = $(output_elt); + var data_src = output_elt.attr("data-src"); + if (!data_src) { + output_elt.html("Error: missing data-src attribute
"); + } else { + output_elt.html("Loading...
"); + $.get(data_src).done(function(data) { + output_elt.html(data); + retrieve_session_materials(output_elt.find(".agenda-frame")); + retrieve_session_materials(output_elt.find(".minutes-frame")); + }); + } + } + + $(document).ready(function() { + $(".modal").on("show.bs.modal", function () { + retrieve_session_modal($(this).find(".session-materials")); + }); + }) +})(); \ No newline at end of file diff --git a/ietf/static/js/agenda_timezone.js b/ietf/static/js/agenda_timezone.js new file mode 100644 index 000000000..56d235843 --- /dev/null +++ b/ietf/static/js/agenda_timezone.js @@ -0,0 +1,229 @@ +// Copyright The IETF Trust 2021, All Rights Reserved + +/* + Timezone support specific to the agenda page + + To properly handle timezones other than local, needs a method to retrieve + the current timezone. Set this by passing a method taking no parameters and + returning the current timezone to the set_current_tz_cb() method. + This should be done before calling anything else in the file. + */ + +var meeting_timezone; +var local_timezone = moment.tz.guess(); + +// get_current_tz_cb must be overwritten using set_current_tz_cb +window.get_current_tz_cb = function () { + throw new Error('Tried to get current timezone before callback registered. Use set_current_tz_cb().') +}; + +// Initialize moments +window.initialize_moments = function () { + var times=$('span.time') + $.each(times, function(i, item) { + item.start_ts = moment.unix(this.getAttribute("data-start-time")).utc(); + item.end_ts = moment.unix(this.getAttribute("data-end-time")).utc(); + if (this.hasAttribute("weekday")) { + item.format=2; + } else { + item.format=1; + } + if (this.hasAttribute("format")) { + item.format = +this.getAttribute("format"); + } + }); + var times=$('[data-slot-start-ts]') + $.each(times, function(i, item) { + item.slot_start_ts = moment.unix(this.getAttribute("data-slot-start-ts")).utc(); + item.slot_end_ts = moment.unix(this.getAttribute("data-slot-end-ts")).utc(); + }); +} + +window.format_time = function (t, tz, fmt) { + var out; + var mtz = meeting_timezone; + if (mtz == "") { + mtz = "UTC"; + } + + switch (fmt) { + case 0: + out = t.tz(tz).format('dddd, ') + '' + + t.tz(tz).format('MMMM Do YYYY, ') + '' + + t.tz(tz).format('HH:mm') + '' + + t.tz(tz).format(' Z z') + ''; + break; + case 1: + // Note, this code does not work if the meeting crosses the + // year boundary. + out = t.tz(tz).format("HH:mm"); + if (+t.tz(tz).dayOfYear() < +t.tz(mtz).dayOfYear()) { + out = out + " (-1)"; + } else if (+t.tz(tz).dayOfYear() > +t.tz(mtz).dayOfYear()) { + out = out + " (+1)"; + } + break; + case 2: + out = t.tz(mtz).format("dddd, ").toUpperCase() + + t.tz(tz).format("HH:mm"); + if (+t.tz(tz).dayOfYear() < +t.tz(mtz).dayOfYear()) { + out = out + " (-1)"; + } else if (+t.tz(tz).dayOfYear() > +t.tz(mtz).dayOfYear()) { + out = out + " (+1)"; + } + break; + case 3: + out = t.utc().format("YYYY-MM-DD"); + break; + case 4: + out = t.tz(tz).format("YYYY-MM-DD HH:mm"); + break; + case 5: + out = t.tz(tz).format("HH:mm"); + break; + } + return out; +} + + +// Format tooltip notice +window.format_tooltip_notice = function (start, end) { + var notice = ""; + + if (end.isBefore()) { + notice = "Event ended " + end.fromNow(); + } else if (start.isAfter()) { + notice = "Event will start " + start.fromNow(); + } else { + notice = "Event started " + start.fromNow() + " and will end " + + end.fromNow(); + } + return '' + notice + ''; +} + +// Format tooltip table +window.format_tooltip_table = function (start, end) { + var current_timezone = get_current_tz_cb(); + var out = 'Timezone | Start | End |
---|---|---|
Meeting timezone: | ' + + format_time(start, meeting_timezone, 0) + ' | ' + + format_time(end, meeting_timezone, 0) + ' |
Local timezone: | ' + + format_time(start, local_timezone, 0) + ' | ' + + format_time(end, local_timezone, 0) + ' |
Selected Timezone: | ' + + format_time(start, current_timezone, 0) + ' | ' + + format_time(end, current_timezone, 0) + ' |
UTC: | ' + + format_time(start, 'UTC', 0) + ' | ' + + format_time(end, 'UTC', 0) + ' |