fix: render meeting schedule editor icons using bootstrap-icons (#3777)

Uses template tags to render `ConstraintName` instances as HTML. Fixes #3557.
This commit is contained in:
Jennifer Richards 2022-04-05 18:57:08 -03:00 committed by GitHub
parent b65182150f
commit 7738319c2a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 147 additions and 38 deletions

View file

@ -879,7 +879,12 @@ class Constraint(models.Model):
- time_relation: preference for a time difference between sessions
- wg_adjacent: request for source WG to be adjacent (directly before or after,
no breaks, same room) the target WG
In the schedule editor, run-time, a couple non-persistent ConstraintName instances
are created for rendering purposes. This is done in
meeting.utils.preprocess_constraints_for_meeting_schedule_editor(). This adds:
- joint_with_groups
- responsible_ad
"""
TIME_RELATION_CHOICES = (
('subsequent-days', 'Schedule the sessions on subsequent days'),

View file

@ -0,0 +1,41 @@
# Copyright The IETF Trust 2022, All Rights Reserved
# -*- coding: utf-8 -*-
"""Custom tags for the schedule editor"""
import debug # pyflakes: ignore
from django import template
from django.utils.html import format_html
register = template.Library()
@register.simple_tag
def constraint_icon_for(constraint_name, count=None):
# icons must be valid HTML and kept up to date with tests.EditorTagTests.test_constraint_icon_for()
icons = {
'conflict': '<span class="encircled">{reversed}1</span>',
'conflic2': '<span class="encircled">{reversed}2</span>',
'conflic3': '<span class="encircled">{reversed}3</span>',
'bethere': '<i class="bi bi-person"></i>{count}',
'timerange': '<i class="bi bi-calendar"></i>',
'time_relation': '&Delta;',
'wg_adjacent': '{reversed}<i class="bi bi-skip-end"></i>',
'chair_conflict': '{reversed}<i class="bi bi-person-circle"></i>',
'tech_overlap': '{reversed}<i class="bi bi-link"></i>',
'key_participant': '{reversed}<i class="bi bi-key"></i>',
'joint_with_groups': '<i class="bi bi-merge"></i>',
'responsible_ad': '<span class="encircled">AD</span>',
}
reversed_suffix = '-reversed'
if constraint_name.slug.endswith(reversed_suffix):
reversed = True
cn = constraint_name.slug[: -len(reversed_suffix)]
else:
reversed = False
cn = constraint_name.slug
return format_html(
icons[cn],
count=count or '',
reversed='-' if reversed else '',
)

View file

@ -1,10 +1,14 @@
# Copyright The IETF Trust 2009-2020, All Rights Reserved
# -*- coding: utf-8 -*-
import debug # pyflakes: ignore
from django.template import Context, Template
from pyquery import PyQuery
from ietf.meeting.factories import FloorPlanFactory, RoomFactory, TimeSlotFactory
from ietf.meeting.templatetags.agenda_custom_tags import AnchorNode
from ietf.meeting.templatetags.editor_tags import constraint_icon_for
from ietf.name.models import ConstraintName
from ietf.utils.test_utils import TestCase
@ -43,3 +47,80 @@ class AgendaCustomTagsTests(TestCase):
f'<span><a href="{context["show_location"].location.floorplan_url()}">show_location</a></span>',
result,
)
class EditorTagsTests(TestCase):
def _supported_constraint_names(self):
"""Get all ConstraintNames that must be supported by the tags"""
constraint_names = set(ConstraintName.objects.filter(used=True))
# Instantiate a couple that are added at run-time in meeting.utils
constraint_names.add(ConstraintName(slug='joint_with_groups', name='joint with groups'))
constraint_names.add(ConstraintName(slug='responsible_ad', name='AD'))
# Reversed names are also added at run-time
reversed = [
ConstraintName(slug=f'{n.slug}-reversed', name=f'{n.name} - reversed')
for n in constraint_names
]
constraint_names.update(reversed)
return constraint_names
def test_constraint_icon_for_supports_all(self):
"""constraint_icon_for tag should render all the necessary ConstraintNames
Relies on ConstraintNames in the names.json fixture being up-to-date
"""
for cn in self._supported_constraint_names():
self.assertGreater(len(constraint_icon_for(cn)), 0)
self.assertGreater(len(constraint_icon_for(cn, count=1)), 0)
def test_constraint_icon_for(self):
"""Constraint icons should render as expected
This is the authoritative definition of what should be rendered for each constraint.
Update this before changing the constraint_icon_for tag.
"""
test_cases = (
# (ConstraintName slug, additional tag parameters, expected output HTML)
('conflict', '', '<span class="encircled">1</span>'),
('conflic2', '', '<span class="encircled">2</span>'),
('conflic3', '', '<span class="encircled">3</span>'),
('conflict-reversed', '', '<span class="encircled">-1</span>'),
('conflic2-reversed', '', '<span class="encircled">-2</span>'),
('conflic3-reversed', '', '<span class="encircled">-3</span>'),
('bethere', '27', '<i class="bi bi-person"></i>27'),
('timerange', '', '<i class="bi bi-calendar"></i>'),
('time_relation', '', '\u0394'), # \u0394 is a capital Greek Delta
('wg_adjacent', '', '<i class="bi bi-skip-end"></i>'),
('wg_adjacent-reversed', '', '-<i class="bi bi-skip-end"></i>'),
('chair_conflict', '', '<i class="bi bi-person-circle"></i>'),
('chair_conflict-reversed', '', '-<i class="bi bi-person-circle"></i>'),
('tech_overlap', '', '<i class="bi bi-link"></i>'),
('tech_overlap-reversed', '', '-<i class="bi bi-link"></i>'),
('key_participant', '', '<i class="bi bi-key"></i>'),
('key_participant-reversed', '', '-<i class="bi bi-key"></i>'),
('joint_with_groups', '', '<i class="bi bi-merge"></i>'),
('responsible_ad', '', '<span class="encircled">AD</span>'),
)
# Create a template with a cn_i context variable for the ConstraintName in each test case.
template = Template(
'{% load editor_tags %}<html>' +
''.join(
f'<div id="test-case-{index}">{{% constraint_icon_for cn_{index} {params} %}}</div>'
for index, (_, params, _) in enumerate(test_cases)
) +
'</html>'
)
# Construct the cn_i in the Context and render.
result = template.render(
Context({
f'cn_{index}': ConstraintName(slug=slug)
for index, (slug, _, _) in enumerate(test_cases)
})
)
q = PyQuery(result)
for index, (slug, params, expected) in enumerate(test_cases):
self.assertHTMLEqual(
q(f'#test-case-{index}').html(),
expected,
f'Unexpected output for {slug} {params}',
)

View file

@ -3632,7 +3632,7 @@ class EditTests(TestCase):
# Now enable the 'chair_conflict' constraint only
chair_conflict = ConstraintName.objects.get(slug='chair_conflict')
chair_conf_label = b'<i class="bi bi-person-plus"/>' # result of etree.tostring(etree.fromstring(editor_label))
chair_conf_label = b'<i class="bi bi-person-circle"/>' # result of etree.tostring(etree.fromstring(editor_label))
meeting.group_conflict_types.add(chair_conflict)
r = self.client.get(url)
q = PyQuery(r.content)

View file

@ -2,7 +2,6 @@
# -*- coding: utf-8 -*-
import datetime
import itertools
import re
import requests
import subprocess
@ -14,8 +13,6 @@ from django.conf import settings
from django.contrib import messages
from django.template.loader import render_to_string
from django.utils.encoding import smart_text
from django.utils.html import format_html
from django.utils.safestring import mark_safe
import debug # pyflakes:ignore
@ -291,14 +288,6 @@ def data_for_meetings_overview(meetings, interim_status=None):
return meetings
def reverse_editor_label(label):
reverse_sign = "-"
m = re.match(r"(<[^>]+>)([^<].*)", label)
if m:
return m.groups()[0] + reverse_sign + m.groups()[1]
else:
return reverse_sign + label
def preprocess_constraints_for_meeting_schedule_editor(meeting, sessions):
# process constraint names - we synthesize extra names to be able
@ -308,7 +297,6 @@ def preprocess_constraints_for_meeting_schedule_editor(meeting, sessions):
joint_with_groups_constraint_name = ConstraintName(
slug='joint_with_groups',
name="Joint session with",
editor_label="<i class=\"bi bi-link\"></i>",
order=8,
)
constraint_names[joint_with_groups_constraint_name.slug] = joint_with_groups_constraint_name
@ -316,7 +304,6 @@ def preprocess_constraints_for_meeting_schedule_editor(meeting, sessions):
ad_constraint_name = ConstraintName(
slug='responsible_ad',
name="Responsible AD",
editor_label="<span class=\"encircled\">AD</span>",
order=9,
)
constraint_names[ad_constraint_name.slug] = ad_constraint_name
@ -327,12 +314,8 @@ def preprocess_constraints_for_meeting_schedule_editor(meeting, sessions):
slug=n.slug + "-reversed",
name="{} - reversed".format(n.name),
)
reverse_n.formatted_editor_label = mark_safe(reverse_editor_label(n.editor_label))
constraint_names[reverse_n.slug] = reverse_n
n.formatted_editor_label = mark_safe(n.editor_label)
n.countless_formatted_editor_label = format_html(n.formatted_editor_label, count="") if "{count}" in n.formatted_editor_label else n.formatted_editor_label
# convert human-readable rules in the database to constraints on actual sessions
constraints = list(meeting.enabled_constraints().prefetch_related('target', 'person', 'timeranges'))

View file

@ -16,7 +16,7 @@ import tarfile
import tempfile
from calendar import timegm
from collections import OrderedDict, Counter, deque, defaultdict
from collections import OrderedDict, Counter, deque, defaultdict, namedtuple
from urllib.parse import unquote
from tempfile import mkstemp
from wsgiref.handlers import format_date_time
@ -39,7 +39,6 @@ from django.template.loader import render_to_string
from django.utils.encoding import force_str
from django.utils.functional import curry
from django.utils.text import slugify
from django.utils.html import format_html
from django.utils.timezone import now
from django.views.decorators.cache import cache_page
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt
@ -558,20 +557,20 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None):
# shared between the conflicting sessions they cover - the JS
# then simply has to detect violations and show the
# preprocessed labels
constrained_sessions_grouped_by_label = defaultdict(set)
for name_id, ts in itertools.groupby(sorted(constraints_for_sessions.get(s.pk, [])), key=lambda t: t[0]):
ConstraintHint = namedtuple('ConstraintHint', 'constraint_name count')
constraint_hints = defaultdict(set)
for name_id, ts in itertools.groupby(sorted(constraints_for_sessions.get(s.pk, [])), key=lambda t: t[0]): # name_id same for each set of ts
ts = list(ts)
session_pks = (t[1] for t in ts)
constraint_name = constraint_names[name_id]
if "{count}" in constraint_name.formatted_editor_label:
for session_pk, grouped_session_pks in itertools.groupby(session_pks):
count = sum(1 for i in grouped_session_pks)
constrained_sessions_grouped_by_label[format_html(constraint_name.formatted_editor_label, count=count)].add(session_pk)
for session_pk, grouped_session_pks in itertools.groupby(session_pks):
# count is the number of instances of session_pk - should only have multiple in the
# case of bethere constraints, where there will be one per person.pk
count = len(list(grouped_session_pks)) # list() needed because iterator has no len()
constraint_hints[ConstraintHint(constraint_names[name_id], count)].add(session_pk)
else:
constrained_sessions_grouped_by_label[constraint_name.formatted_editor_label].update(session_pks)
s.constrained_sessions = list(constrained_sessions_grouped_by_label.items())
# The constraint hint key is a tuple (ConstraintName, count). Value is the set of sessions pks that
# should trigger that hint.
s.constraint_hints = list(constraint_hints.items())
s.formatted_constraints = formatted_constraints_for_sessions.get(s.pk, {})
s.other_sessions = [s_other for s_other in sessions_for_group.get(s.group_id) if s != s_other]

View file

@ -105,7 +105,7 @@ $(function () {
let sessionInfoContainer = schedEditor.find(".scheduling-panel .session-info-container");
sessionInfoContainer.html(jQuery(element).find(".session-info").html());
sessionInfoContainer.find("[data-original-title]").tooltip();
sessionInfoContainer.find("[data-bs-original-title]").tooltip();
sessionInfoContainer.find(".time").text(jQuery(element).closest(".timeslot").data('scheduledatlabel'));

View file

@ -1,4 +1,4 @@
{% load person_filters %}
{% load person_filters editor_tags %}
<div id="session{{ session.pk }}"
class="session {% if not session.group.parent.scheduling_color %}untoggleable-by-parent{% endif %} {% if session.parent_acronym %}parent-{{ session.parent_acronym }}{% endif %} purpose-{{ session.purpose.slug }} {% if session.readonly %}readonly{% endif %} {% if not session.on_agenda %}off-agenda{% endif %}"
style="width:{{ session.layout_width }}em;"
@ -13,10 +13,10 @@
<span class="requested-duration">{{ session.requested_duration_in_hours|floatformat }}h</span>
{% if session.attendees != None %}<span class="attendees">&middot; {{ session.attendees }}</span>{% endif %}
{% if session.comments %}<span class="comments"><i class="bi bi-chat"></i></span>{% endif %}
{% if session.constrained_sessions %}
{% if session.constraint_hints %}
<span class="constraints">
{% for label, sessions in session.constrained_sessions %}
<span data-sessions="{{ sessions|join:"," }}">{{ label }}</span>
{% for hint, sessions in session.constraint_hints %}
<span data-sessions="{{ sessions|join:"," }}">{% constraint_icon_for hint.constraint_name hint.count %}</span>
{% endfor %}
</span>
{% endif %}
@ -65,7 +65,7 @@
<div class="formatted-constraints">
{% for constraint_name, values in session.formatted_constraints.items %}
<div>
<span title="{{ constraint_name.name }}">{{ constraint_name.countless_formatted_editor_label }}</span>: {{ values|join:", " }}
<span title="{{ constraint_name.name }}">{% constraint_icon_for constraint_name %}</span>: {{ values|join:", " }}
</div>
{% endfor %}
</div>