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('HH:mm') + ''; + 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 = ''; + if (meeting_timezone !== "") { + out += ''; + } + out += ''; + if (current_timezone !== 'UTC') { + out += ''; + } + out += ''; + out += '
TimezoneStartEnd
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) + '
' + format_tooltip_notice(start, end); + return out; +} + +// Format tooltip for item +window.format_tooltip = function (start, end) { + return '
' + + format_tooltip_table(start, end) + + '
'; +} + +// Add tooltips +window.add_tooltips = function () { + $('span.time').each(function () { + var tooltip = $(format_tooltip(this.start_ts, this.end_ts)); + tooltip[0].start_ts = this.start_ts; + tooltip[0].end_ts = this.end_ts; + tooltip[0].ustart_ts = moment(this.start_ts).add(-2, 'hours'); + tooltip[0].uend_ts = moment(this.end_ts).add(2, 'hours'); + $(this).parent().append(tooltip); + }); +} + +// Update times on the agenda based on the selected timezone +window.update_times = function (newtz) { + $('span.current-tz').html(newtz); + $('span.time').each(function () { + if (this.format == 4) { + var tz = this.start_ts.tz(newtz).format(" z"); + if (this.start_ts.tz(newtz).dayOfYear() == + this.end_ts.tz(newtz).dayOfYear()) { + $(this).html(format_time(this.start_ts, newtz, this.format) + + '-' + format_time(this.end_ts, newtz, 5) + tz); + } else { + $(this).html(format_time(this.start_ts, newtz, this.format) + + '-' + + format_time(this.end_ts, newtz, this.format) + tz); + } + } else { + $(this).html(format_time(this.start_ts, newtz, this.format) + '-' + + format_time(this.end_ts, newtz, this.format)); + } + }); + update_tooltips_all(); + update_clock(); +} + +// Highlight ongoing based on the current time +window.highlight_ongoing = function () { + $("div#now").remove("#now"); + $('.ongoing').removeClass("ongoing"); + var agenda_rows=$('[data-slot-start-ts]') + agenda_rows = agenda_rows.filter(function() { + return moment().isBetween(this.slot_start_ts, this.slot_end_ts); + }); + agenda_rows.addClass("ongoing"); + agenda_rows.first().children("th, td"). + prepend($('
')); +} + +// Update tooltips +window.update_tooltips = function () { + var tooltips=$('.timetooltiptext'); + tooltips.filter(function() { + return moment().isBetween(this.ustart_ts, this.uend_ts); + }).each(function () { + $(this).html(format_tooltip_table(this.start_ts, this.end_ts)); + }); +} + +// Update all tooltips +window.update_tooltips_all = function () { + var tooltips=$('.timetooltiptext'); + tooltips.each(function () { + $(this).html(format_tooltip_table(this.start_ts, this.end_ts)); + }); +} + +// Update clock +window.update_clock = function () { + $('#current-time').html(format_time(moment(), get_current_tz_cb(), 0)); +} + +$.urlParam = function(name) { + var results = new RegExp('[\?&]' + name + '=([^&#]*)').exec(window.location.href); + if (results == null) { + return null; + } else { + return results[1] || 0; + } +} + +window.init_timers = function () { + var fast_timer = 60000 / (speedup > 600 ? 600 : speedup); + update_clock(); + highlight_ongoing(); + setInterval(function() { update_clock(); }, fast_timer); + setInterval(function() { highlight_ongoing(); }, fast_timer); + setInterval(function() { update_tooltips(); }, fast_timer); + setInterval(function() { update_tooltips_all(); }, 3600000 / speedup); +} + +// set method used to find current time zone +window.set_current_tz_cb = function (fn) { + get_current_tz_cb = fn; +} \ No newline at end of file diff --git a/ietf/static/js/moment-timezone-with-data-10-year-range.js b/ietf/static/js/moment-timezone-with-data-10-year-range.js new file mode 100644 index 000000000..33d1580e9 --- /dev/null +++ b/ietf/static/js/moment-timezone-with-data-10-year-range.js @@ -0,0 +1 @@ +import "moment-timezone/builds/moment-timezone-with-data-10-year-range"; \ No newline at end of file diff --git a/ietf/static/js/moment.js b/ietf/static/js/moment.js new file mode 100644 index 000000000..1a1c65de7 --- /dev/null +++ b/ietf/static/js/moment.js @@ -0,0 +1,3 @@ +const moment = require("moment"); + +global.moment = moment; \ No newline at end of file diff --git a/ietf/static/js/timezone.js b/ietf/static/js/timezone.js new file mode 100644 index 000000000..3457b4299 --- /dev/null +++ b/ietf/static/js/timezone.js @@ -0,0 +1,74 @@ +// Copyright The IETF Trust 2021, All Rights Reserved + +/* + Timezone selection handling. Relies on the moment.js library. + + To use, create one (or more) select inputs with class "tz-select". When the initialize() + method is called, the options in the select will be replaced with the recognized time zone + names. Time zone can be changed via the select input or by calling the use() method with + the name of a time zone (or 'local' to guess the user's local timezone). + */ +window.ietf_timezone; // public interface + +(function () { + 'use strict'; + // Callback for timezone change - called after current_timezone is updated + var timezone_change_callback; + var current_timezone; + + // Select timezone to use. Arg is name of a timezone or 'local' to guess local tz. + function use_timezone (newtz) { + // Guess local timezone if necessary + if (newtz.toLowerCase() === 'local') { + newtz = moment.tz.guess() + } + + if (current_timezone !== newtz) { + current_timezone = newtz + // Update values of tz-select inputs but do not trigger change event + $('select.tz-select').val(newtz) + if (timezone_change_callback) { + timezone_change_callback(newtz) + } + } + } + + /* Initialize timezone system + * + * This will set the timezone to the value of 'current'. Set up the tz_change callback + * before initializing. + */ + function timezone_init (current) { + var tz_names = moment.tz.names() + var select = $('select.tz-select') + + select.empty() + $.each(tz_names, function (i, item) { + if (current === item) { + select.append($('