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:
parent
b65182150f
commit
7738319c2a
|
@ -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'),
|
||||
|
|
41
ietf/meeting/templatetags/editor_tags.py
Normal file
41
ietf/meeting/templatetags/editor_tags.py
Normal 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': 'Δ',
|
||||
'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 '',
|
||||
)
|
|
@ -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}',
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'))
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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'));
|
||||
|
||||
|
|
|
@ -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">· {{ 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>
|
||||
|
|
Loading…
Reference in a new issue