Use updated filter scheme for week-view and agenda
- Legacy-Id: 18457
This commit is contained in:
parent
b1e3c1fe92
commit
d9d5234217
|
@ -285,10 +285,6 @@ class SlideReorderTests(MeetingTestCase):
|
|||
|
||||
@skipIf(skip_selenium, skip_message)
|
||||
class AgendaTests(MeetingTestCase):
|
||||
# Groups whose display logic is inverted in agenda.html. These have
|
||||
# toggles with class 'pickviewneg' in the template.
|
||||
PICKVIEWNEG = ['iepg', 'tools', 'edu', 'ietf', 'iesg', 'iab']
|
||||
|
||||
def setUp(self):
|
||||
super(AgendaTests, self).setUp()
|
||||
self.meeting = make_meeting_test_data()
|
||||
|
@ -377,40 +373,75 @@ class AgendaTests(MeetingTestCase):
|
|||
self.assertEqual(result[4], ['item1', 'item3'], 'Removing middle item from list failed')
|
||||
self.assertEqual(result[5], ['item1', 'item2'], 'Removing last item from list failed')
|
||||
|
||||
def do_agenda_view_filter_test(self, querystring, visible_groups=()):
|
||||
self.login()
|
||||
self.driver.get(self.absreverse('ietf.meeting.views.agenda') + querystring)
|
||||
self.assert_agenda_item_visibility(visible_groups)
|
||||
weekview_iframe = self.driver.find_element_by_id('weekview')
|
||||
if len(querystring) == 0:
|
||||
self.assertFalse(weekview_iframe.is_displayed(), 'Weekview should be hidden when filters off')
|
||||
else:
|
||||
self.assertTrue(weekview_iframe.is_displayed(), 'Weekview should be visible when filters on')
|
||||
self.driver.switch_to.frame(weekview_iframe)
|
||||
self.assert_weekview_item_visibility(visible_groups)
|
||||
self.driver.switch_to.default_content()
|
||||
|
||||
def test_agenda_view_filter_show_one(self):
|
||||
"""Filtered agenda view should display only matching rows (one group selected)"""
|
||||
self.login()
|
||||
self.driver.get(self.absreverse('ietf.meeting.views.agenda') + '?show=mars')
|
||||
self.assert_agenda_item_visibility(['mars'] + self.PICKVIEWNEG) # ames and secretariat not selected
|
||||
self.do_agenda_view_filter_test('?show=mars', ['mars'])
|
||||
|
||||
def test_agenda_view_filter_show_two(self):
|
||||
"""Filtered agenda view should display only matching rows (two groups selected)"""
|
||||
self.login()
|
||||
self.driver.get(self.absreverse('ietf.meeting.views.agenda') + '?show=mars,ames')
|
||||
self.assert_agenda_item_visibility(['mars', 'ames'] + self.PICKVIEWNEG) # secretariat not selected
|
||||
self.do_agenda_view_filter_test('?show=mars,ames', ['mars', 'ames'])
|
||||
|
||||
def test_agenda_view_filter_all(self):
|
||||
"""Filtered agenda view should display only matching rows (all groups selected)"""
|
||||
self.login()
|
||||
self.driver.get(self.absreverse('ietf.meeting.views.agenda'))
|
||||
self.assert_agenda_item_visibility()
|
||||
self.do_agenda_view_filter_test('', None) # None means all should be visible
|
||||
|
||||
def test_agenda_view_filter_hide(self):
|
||||
self.login()
|
||||
self.driver.get(self.absreverse('ietf.meeting.views.agenda') + '?hide=ietf')
|
||||
self.assert_agenda_item_visibility([g for g in self.PICKVIEWNEG if g != 'ietf'])
|
||||
self.do_agenda_view_filter_test('?hide=ietf', [])
|
||||
|
||||
def test_agenda_view_filter_show_and_hide(self):
|
||||
self.login()
|
||||
self.driver.get(self.absreverse('ietf.meeting.views.agenda') + '?show=mars&hide=ietf')
|
||||
self.assert_agenda_item_visibility(
|
||||
['mars'] + [g for g in self.PICKVIEWNEG if g != 'ietf']
|
||||
)
|
||||
self.do_agenda_view_filter_test('?show=mars&hide=ietf', ['mars'])
|
||||
|
||||
def assert_agenda_item_visibility(self, visible_groups=()):
|
||||
def test_agenda_view_filter_show_and_hide_same_group(self):
|
||||
self.do_agenda_view_filter_test('?show=mars&hide=mars', [])
|
||||
|
||||
def test_agenda_view_filter_showtypes(self):
|
||||
self.do_agenda_view_filter_test('?showtypes=plenary', ['ietf']) # ietf has a plenary session
|
||||
|
||||
def test_agenda_view_filter_hidetypes(self):
|
||||
self.do_agenda_view_filter_test('?hidetypes=plenary', [])
|
||||
|
||||
def test_agenda_view_filter_showtypes_and_hidetypes(self):
|
||||
self.do_agenda_view_filter_test('?showtypes=plenary&hidetypes=regular', ['ietf']) # ietf has a plenary session
|
||||
|
||||
def test_agenda_view_filter_showtypes_and_hidetypes_same_type(self):
|
||||
self.do_agenda_view_filter_test('?showtypes=plenary&hidetypes=plenary', [])
|
||||
|
||||
def test_agenda_view_filter_show_and_showtypes(self):
|
||||
self.do_agenda_view_filter_test('?show=mars&showtypes=plenary', ['mars', 'ietf']) # ietf has a plenary session
|
||||
|
||||
def test_agenda_view_filter_show_and_hidetypes(self):
|
||||
self.do_agenda_view_filter_test('?show=ietf,mars&hidetypes=plenary', ['mars']) # ietf has a plenary session
|
||||
|
||||
def test_agenda_view_filter_hide_and_hidetypes(self):
|
||||
self.do_agenda_view_filter_test('?hide=ietf,mars&hidetypes=plenary', [])
|
||||
|
||||
def test_agenda_view_filter_show_hide_and_showtypes(self):
|
||||
self.do_agenda_view_filter_test('?show=mars&hide=ames&showtypes=plenary,regular', ['mars', 'ietf']) # ietf has plenary session
|
||||
|
||||
def test_agenda_view_filter_show_hide_and_hidetypes(self):
|
||||
self.do_agenda_view_filter_test('?show=mars,ietf&hide=ames&hidetypes=plenary', ['mars']) # ietf has plenary session
|
||||
|
||||
def test_agenda_view_filter_all_params(self):
|
||||
self.do_agenda_view_filter_test('?show=secretariat,ietf&hide=ames&showtypes=regular&hidetypes=plenary',
|
||||
['secretariat', 'mars'])
|
||||
|
||||
def assert_agenda_item_visibility(self, visible_groups=None):
|
||||
"""Assert that correct items are visible in current browser window
|
||||
|
||||
If visible_groups is empty (the default), expects all items to be visible.
|
||||
If visible_groups is None (the default), expects all items to be visible.
|
||||
"""
|
||||
for item in self.get_expected_items():
|
||||
row_id = self.row_id_for_item(item)
|
||||
|
@ -419,11 +450,33 @@ class AgendaTests(MeetingTestCase):
|
|||
except NoSuchElementException:
|
||||
item_row = None
|
||||
self.assertIsNotNone(item_row, 'No row for schedule item "%s"' % row_id)
|
||||
if len(visible_groups) == 0 or item.session.group.acronym in visible_groups:
|
||||
if visible_groups is None or item.session.group.acronym in visible_groups:
|
||||
self.assertTrue(item_row.is_displayed(), 'Row for schedule item "%s" is not displayed but should be' % row_id)
|
||||
else:
|
||||
self.assertFalse(item_row.is_displayed(), 'Row for schedule item "%s" is displayed but should not be' % row_id)
|
||||
|
||||
def assert_weekview_item_visibility(self, visible_groups=None):
|
||||
for item in self.get_expected_items():
|
||||
if item.session.name:
|
||||
label = item.session.name
|
||||
elif item.timeslot.type_id == 'break':
|
||||
label = item.timeslot.name
|
||||
elif item.session.group:
|
||||
label = item.session.group.name
|
||||
else:
|
||||
label = 'Free Slot'
|
||||
|
||||
try:
|
||||
item_div = self.driver.find_element_by_xpath('//div/span[contains(text(),"%s")]/..' % label)
|
||||
except NoSuchElementException:
|
||||
item_div = None
|
||||
|
||||
if visible_groups is None or item.session.group.acronym in visible_groups:
|
||||
self.assertIsNotNone(item_div, 'No weekview entry for "%s" (%s)' % (label, item.slug()))
|
||||
self.assertTrue(item_div.is_displayed(), 'Entry for "%s (%s)" is not displayed but should be' % (label, item.slug()))
|
||||
else:
|
||||
self.assertIsNone(item_div, 'Unexpected weekview entry for "%s" (%s)' % (label, item.slug()))
|
||||
|
||||
def test_agenda_view_group_filter_toggle(self):
|
||||
"""Clicking a group toggle enables/disables agenda filtering"""
|
||||
group_acronym = 'mars'
|
||||
|
@ -449,7 +502,7 @@ class AgendaTests(MeetingTestCase):
|
|||
group_button.click()
|
||||
|
||||
# Check visibility
|
||||
self.assert_agenda_item_visibility([group_acronym] + self.PICKVIEWNEG)
|
||||
self.assert_agenda_item_visibility([group_acronym])
|
||||
|
||||
# Click the group button again
|
||||
group_button = WebDriverWait(self.driver, 2).until(
|
||||
|
|
|
@ -801,7 +801,7 @@ class MeetingTests(TestCase):
|
|||
# ames regular session should be suppressed
|
||||
self.do_ical_filter_test(
|
||||
meeting,
|
||||
querystring='?show=mars&hide=ames&showtypes=plenary,regular',
|
||||
querystring='?show=ietf&hide=ames&showtypes=regular',
|
||||
expected_session_summaries=[
|
||||
'IETF Plenary',
|
||||
'mars - Martian Special Interest Group',
|
||||
|
|
|
@ -120,12 +120,12 @@
|
|||
{% endif %}
|
||||
<p>Also show special sessions of these groups:</p>
|
||||
<div class="btn-group btn-group-justified">
|
||||
<div class="btn-group"><button class="btn btn-default pickviewneg active iepg"> IEPG</button></div>
|
||||
<div class="btn-group"><button class="btn btn-default pickviewneg active tools"> Tools</button></div>
|
||||
<div class="btn-group"><button class="btn btn-default pickviewneg active edu"> EDU</button></div>
|
||||
<div class="btn-group"><button class="btn btn-default pickviewneg active ietf"> IETF</button></div>
|
||||
<div class="btn-group"><button class="btn btn-default pickviewneg active iesg"> IESG</button></div>
|
||||
<div class="btn-group"><button class="btn btn-default pickviewneg active iab"> IAB</button></div>
|
||||
<div class="btn-group"><button class="btn btn-default pickview iepg"> IEPG</button></div>
|
||||
<div class="btn-group"><button class="btn btn-default pickview tools"> Tools</button></div>
|
||||
<div class="btn-group"><button class="btn btn-default pickview edu"> EDU</button></div>
|
||||
<div class="btn-group"><button class="btn btn-default pickview ietf"> IETF</button></div>
|
||||
<div class="btn-group"><button class="btn btn-default pickview iesg"> IESG</button></div>
|
||||
<div class="btn-group"><button class="btn btn-default pickview iab"> IAB</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -362,10 +362,13 @@
|
|||
function parse_query_params(qs) {
|
||||
var params = {};
|
||||
qs = qs.replace(/^\?/, '');
|
||||
$.each(qs.split('&'), function(i, v) {
|
||||
var toks = v.split('=', 2)
|
||||
params[toks[0]] = toks[1].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;
|
||||
}
|
||||
|
||||
|
@ -374,43 +377,56 @@
|
|||
return qparams[filt] ? qparams[filt].split(',') : [];
|
||||
}
|
||||
|
||||
function toggle_visibility() {
|
||||
var qparams = parse_query_params(window.location.search);
|
||||
var show_groups = get_filter_from_qparams(qparams, 'show');
|
||||
var hide_groups = get_filter_from_qparams(qparams, 'hide');
|
||||
function get_filter_params(qparams) {
|
||||
return {
|
||||
show_groups: get_filter_from_qparams(qparams, 'show'),
|
||||
hide_groups: get_filter_from_qparams(qparams, 'hide'),
|
||||
show_types: get_filter_from_qparams(qparams, 'showtypes'),
|
||||
hide_types: get_filter_from_qparams(qparams, 'hidetypes'),
|
||||
};
|
||||
}
|
||||
|
||||
function toggle_visibility(filter_params) {
|
||||
// reset UI elements to default state
|
||||
$(".pickview").removeClass("active disabled");
|
||||
$(".pickviewneg").addClass("active");
|
||||
|
||||
if (show_groups.length || hide_groups.length) {
|
||||
// if groups were selected for filtering, hide all rows that are
|
||||
// hidden by default, show all rows that are shown by default
|
||||
if (filter_params['show_groups'].length ||
|
||||
filter_params['hide_groups'].length ||
|
||||
filter_params['show_types'].length ||
|
||||
filter_params['hide_types'].length
|
||||
) {
|
||||
// if groups were selected for filtering, hide all rows by default
|
||||
$('[id^="row-"]').hide();
|
||||
$.each($(".pickviewneg").text().trim().split(/ +/), function (i, v) {
|
||||
v = v.trim().toLowerCase();
|
||||
$('[id^="row-"]').filter('[id*="-' + v + '"]').show();
|
||||
});
|
||||
|
||||
// show the customizer
|
||||
$("#customize").collapse("show");
|
||||
|
||||
// loop through the has items and change the UI element and row visibilities accordingly
|
||||
$.each(hide_groups, function (i, v) {
|
||||
// this is a "negative" item: when present, hide these rows
|
||||
$('[id^="row-"]').filter('[id*="-' + v + '"]').hide();
|
||||
$(".view." + v).find("button").removeClass("active disabled");
|
||||
$("button.pickviewneg." + v).removeClass("active");
|
||||
});
|
||||
$.each(show_groups, function (i, v) {
|
||||
// this is a regular item: when present, show these rows
|
||||
$.each(filter_params['show_groups'], function (i, v) {
|
||||
// this is a regular item by wg: when present, show these rows
|
||||
$('[id^="row-"]').filter('[id*="-' + v + '"]').show();
|
||||
$(".view." + v).find("button").addClass("active disabled");
|
||||
$("button.pickview." + v).addClass("active");
|
||||
});
|
||||
$.each(filter_params['show_types'], function (i, v) {
|
||||
// this is a regular item by type: when present, show these rows
|
||||
$('[id^="row-"]').filter('[timeslot-type*="' + v + '"]').show();
|
||||
});
|
||||
$.each(filter_params['hide_groups'], function (i, v) {
|
||||
// this is a "negative" item by wg: when present, hide these rows
|
||||
$('[id^="row-"]').filter('[id*="-' + v + '"]').hide();
|
||||
$(".view." + v).find("button").removeClass("active disabled");
|
||||
$("button.pickviewneg." + v).removeClass("active");
|
||||
});
|
||||
$.each(filter_params['hide_types'], function (i, v) {
|
||||
// this is a "negative" item by type: when present, hide these rows
|
||||
$('[id^="row-"]').filter('[timeslot-type*="' + v + '"]').hide();
|
||||
});
|
||||
|
||||
// show the week view
|
||||
$("#weekview").attr("src", "week-view.html" + window.location.search).removeClass("hidden");
|
||||
update_weekview();
|
||||
$("#weekview").removeClass("hidden");
|
||||
|
||||
// show the custom .ics link
|
||||
$("#ical-link").attr("href",$("#ical-link").attr("href").split("?")[0]+window.location.search);
|
||||
|
@ -426,16 +442,14 @@
|
|||
$(".pickview, .pickviewneg").click(function () {
|
||||
// Get clicked item label
|
||||
var item = $(this).text().trim().toLowerCase();
|
||||
var qparams = parse_query_params(window.location.search);
|
||||
var show_groups = get_filter_from_qparams(qparams, 'show');
|
||||
var hide_groups = get_filter_from_qparams(qparams, 'hide');
|
||||
var fp = get_filter_params(parse_query_params(window.location.search));
|
||||
|
||||
if ($(this).hasClass("pickviewneg")) {
|
||||
toggle_list_item(hide_groups, item);
|
||||
toggle_list_item(fp['hide_groups'], item);
|
||||
} else {
|
||||
toggle_list_item(show_groups, item);
|
||||
toggle_list_item(fp['show_groups'], item);
|
||||
}
|
||||
update_filters(show_groups, hide_groups);
|
||||
update_filters(fp);
|
||||
});
|
||||
|
||||
/* Add to list if not present, remove if present */
|
||||
|
@ -448,14 +462,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
function update_filters(show, hide) {
|
||||
function update_filters(filter_params) {
|
||||
var qparams = [];
|
||||
var search = '';
|
||||
if (show.length > 0) {
|
||||
qparams.push('show=' + show.join());
|
||||
if (filter_params['show_groups'].length > 0) {
|
||||
qparams.push('show=' + filter_params['show_groups'].join());
|
||||
}
|
||||
if (hide.length > 0) {
|
||||
qparams.push('hide=' + hide.join());
|
||||
if (filter_params['hide_groups'].length > 0) {
|
||||
qparams.push('hide=' + filter_params['hide_groups'].join());
|
||||
}
|
||||
if (filter_params['show_types'].length > 0) {
|
||||
qparams.push('showtypes=' + filter_params['show_types'].join());
|
||||
}
|
||||
if (filter_params['hide_types'].length > 0) {
|
||||
qparams.push('hidetypes=' + filter_params['hide_types'].join());
|
||||
}
|
||||
if (qparams.length > 0) {
|
||||
search = '?' + qparams.join('&');
|
||||
|
@ -466,15 +486,32 @@
|
|||
if (window.history && window.history.replaceState) {
|
||||
// Keep current origin, replace search string, no page reload
|
||||
history.replaceState({}, document.title, new_url);
|
||||
toggle_visibility();
|
||||
toggle_visibility(filter_params);
|
||||
} else {
|
||||
// No window.history.replaceState support, page reload required
|
||||
window.location = new_url;
|
||||
}
|
||||
}
|
||||
|
||||
function update_weekview() {
|
||||
var wv_iframe = document.getElementById('weekview');
|
||||
var wv_window = wv_iframe.contentWindow;
|
||||
var new_url = 'week-view.html' + window.location.search;
|
||||
if (wv_iframe.src && wv_window.history && wv_window.history.replaceState) {
|
||||
wv_window.history.replaceState({}, '', new_url);
|
||||
wv_window.draw_calendar()
|
||||
} else {
|
||||
// ho history.replaceState, page reload required
|
||||
wv_iframe.src = new_url;
|
||||
}
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
toggle_visibility();
|
||||
toggle_visibility(
|
||||
get_filter_params(
|
||||
parse_query_params(window.location.search)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
$(".modal").on("show.bs.modal", function () {
|
||||
|
|
|
@ -107,33 +107,60 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
//===========================================================================
|
||||
|
||||
function is_visible(include) {
|
||||
function parse_query_params(qs) {
|
||||
var params = {};
|
||||
qs = 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;
|
||||
}
|
||||
|
||||
//===========================================================================
|
||||
|
||||
function get_filter_from_qparams(qparams, filt) {
|
||||
return qparams[filt] ? qparams[filt].split(',') : [];
|
||||
}
|
||||
|
||||
function get_filter_params(qparams) {
|
||||
return {
|
||||
show_groups: get_filter_from_qparams(qparams, 'show'),
|
||||
hide_groups: get_filter_from_qparams(qparams, 'hide'),
|
||||
show_types: get_filter_from_qparams(qparams, 'showtypes'),
|
||||
hide_types: get_filter_from_qparams(qparams, 'hidetypes'),
|
||||
};
|
||||
}
|
||||
//===========================================================================
|
||||
|
||||
function is_visible(query_params) {
|
||||
// Returns a method to filter objects for visibility
|
||||
// Accepts show, hide, showtypes, and hidetypes filters. Also accepts
|
||||
// '@<state>' to show sessions in a particular state (e.g., @bof).
|
||||
// Current types are:
|
||||
// Session, Other, Break, Plenary
|
||||
var fp = get_filter_params(query_params);
|
||||
|
||||
return function (item) {
|
||||
// "-wgname" will remove a working group from the output.
|
||||
// "~Type" will add that type to the output.
|
||||
// "-~Type" will remove that type from the output
|
||||
// "@bof" will include all BOFs
|
||||
// Current types are:
|
||||
// Session, Other, Break, Plenary
|
||||
var item_group = (item.group || '').toLowerCase();
|
||||
var item_type = (item.type || '').toLowerCase();
|
||||
var item_area = (item.area || '').toLowerCase();
|
||||
var item_state = (item.state || '').toLowerCase();
|
||||
|
||||
if ("group" in item) {
|
||||
if (include[(item.group).toLowerCase()]) { return true; }
|
||||
if (include["-"+(item.group).toLowerCase()]) { return false; }
|
||||
if ((fp['hide_groups'].indexOf(item_group) >= 0) ||
|
||||
(fp['hide_types'].indexOf(item_type) >= 0)) {
|
||||
return false;
|
||||
}
|
||||
if ("state" in item) {
|
||||
if (include["@"+(item.state).toLowerCase()]) { return true; }
|
||||
}
|
||||
if (include["~"+(item.type).toLowerCase()]) { return true; }
|
||||
if (include["-~"+(item.type).toLowerCase()]) { return false; }
|
||||
if ("area" in item) {
|
||||
if (include[(item.area).toLowerCase()]) { return true; }
|
||||
}
|
||||
if (item.type === "Plenary") { return true; }
|
||||
if (item.type === "Other") { return true; }
|
||||
|
||||
return false;
|
||||
return ((fp['show_groups'].indexOf(item_group) >= 0) ||
|
||||
(fp['show_groups'].indexOf(item_area) >= 0) ||
|
||||
(fp['show_types'].indexOf(item_type) >= 0) ||
|
||||
query_params['@'+item_state]);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -143,16 +170,23 @@
|
|||
var width = document.body.clientWidth;
|
||||
var height = document.body.clientHeight;
|
||||
|
||||
var include = {};
|
||||
window.location.hash.replace("#",'').split(',').forEach(function(key){
|
||||
include[(key + "").toLowerCase()] = true;
|
||||
});
|
||||
var visible_items = all_items;
|
||||
var qs = window.location.search;
|
||||
if (qs.length > 1) {
|
||||
visible_items = visible_items.filter(is_visible(parse_query_params(qs)));
|
||||
}
|
||||
|
||||
var visible_items = all_items.filter(is_visible(include));
|
||||
|
||||
var start_day = visible_items[0].day;
|
||||
var start_day;
|
||||
var day_start;
|
||||
if (visible_items.length > 0) {
|
||||
start_day = visible_items[0].day;
|
||||
day_start = visible_items[0].start_time;
|
||||
} else {
|
||||
// fallback in case all items were filtered
|
||||
start_day = all_items[0].day;
|
||||
day_start = all_items[0].start_time;
|
||||
}
|
||||
var end_day = start_day;
|
||||
var day_start = visible_items[0].start_time;
|
||||
var day_end = 0;
|
||||
|
||||
compute_swimlanes(visible_items);
|
||||
|
@ -324,6 +358,11 @@
|
|||
|
||||
document.body.appendChild(e);
|
||||
});
|
||||
|
||||
// Div to indicate rendering has occurred, for testing purposes.
|
||||
var elt = document.createElement('div');
|
||||
elt.id = 'wv-end';
|
||||
document.body.appendChild(elt);
|
||||
}
|
||||
|
||||
//===========================================================================
|
||||
|
|
Loading…
Reference in a new issue