Repository: UAlbertaALTLab/morphodict (language: python)

Issue:
Extend relabeling choice to paradigm layouts
Extending on #729, apply the choice of relabeling mode to paradigm layouts as well.


Discussion / hints:

Fix patch:
diff --git a/cypress/integration/paradigm.spec.js b/cypress/integration/paradigm.spec.js
index 2efecf80b..c0b63aaa5 100644
--- a/cypress/integration/paradigm.spec.js
+++ b/cypress/integration/paradigm.spec.js
@@ -119,9 +119,7 @@ describe('paradigms are visitable from link', () => {
     // DIMINUTIVE is an exclusive linguistic term for FULL paradigms
     cy.get('[data-cy=paradigm]').contains('DIMINUTIVE')
   })
-}
-)
-
+})
 
 describe('paradigms can be toggled by the show more/less button', () => {
   it('shows basic, full, linguistic, and basic paradigm in sequence', () => {
@@ -146,5 +144,45 @@ describe('paradigms can be toggled by the show more/less button', () => {
     cy.get('[data-cy=paradigm]').contains('Something is happening now')
 
   })
-}
-)
+})
+
+// Can lift skip off this once admin can be tested.
+describe.skip('Paradigm labels', () => {
+  let lemma = 'nipâw'
+  let englishLabel = 'they'
+  let nehiyawewinLabel = 'wiyanaw'
+  let linguisticLabel = '3s'
+
+  it('should appear in plain English by default', () => {
+    cy.visitLemma(lemma, { 'paradigm-size': 'FULL'})
+
+    cy.get('[data-cy=paradigm]')
+      .contains('th[scope=row]', englishLabel)
+  })
+
+  it('should appear in nêhiyawêwin (Plains Cree)', () => {
+    cy.visitLemma(lemma, { 'paradigm-size': 'FULL'})
+
+    cy.get('[data-cy=open-paradigm-label-switcher]')
+      .click()
+    cy.get('[data-cy=paradigm-label-options]')
+      .contains(/nêhiyawêwin/i)
+      .click()
+
+    cy.get('[data-cy=paradigm]')
+      .contains('th[scope=row]', nehiyawewinLabel)
+  })
+
+  it('should appear using lingustic terminology', () => {
+    cy.visitLemma(lemma, { 'paradigm-size': 'FULL'})
+
+    cy.get('[data-cy=open-paradigm-label-switcher]')
+      .click()
+    cy.get('[data-cy=paradigm-label-options]')
+      .contains(/linguistic/i)
+      .click()
+
+    cy.get('[data-cy=paradigm]')
+      .contains('th[scope=row]', linguisticLabel)
+  })
+})
diff --git a/frontend/css/styles.css b/frontend/css/styles.css
index 3e80f8899..9c0a86b45 100644
--- a/frontend/css/styles.css
+++ b/frontend/css/styles.css
@@ -1135,30 +1135,6 @@ a.multiple-recordings__action-button {
   z-index: 100;
 }
 
-/**
- * Creates an area behind the menu pop-up that, when clicked/touched, will
- * inadvertently toggle the menu away.
- */
-.toggle-box[open] > .toggle-box__toggle::before {
-  content: " ";
-
-  /* Position */
-  position: fixed;
-  top: 0;
-  right: 0;
-  bottom: 0;
-  left: 0;
-
-  /* Display & Box model */
-  display: block;
-  z-index: 80;
-
-  /* Color */
-  background: transparent;
-
-  /* Other */
-  cursor: default;
-}
 
 
 /*********************************** MENU ***********************************/
@@ -1224,6 +1200,60 @@ a.menu-choice__label:active {
   background-color: var(--menu-selected-bg-color);
 }
 
+/********************************* MENU BAR *********************************/
+
+/**
+ * BLOCK: menu-bar
+ *
+ * Display a whole bunch of options in a horizontal menu bar.
+ */
+.menu-bar {
+  display: flex;
+  flex-direction: row;
+}
+
+/**
+ * ELEMENT
+ *
+ * One option in the menu bar.
+ */
+.menu-bar__item {
+  width: max-content;
+  flex: 0 auto;
+}
+
+/**
+ * MODIFIER align-end
+ *
+ * Have all the elements on the end of block.
+ * For left-to-right scripts (e.g., English, Cree), this means the elements
+ * will be right aligned.
+ */
+.menu-bar--align-end {
+  justify-content: flex-end;
+}
+
+/******************************** DROP DOWN *********************************/
+.dropdown {
+  position: relative;
+}
+
+.dropdown--align-end:dir(ltr) .dropdown__menu {
+  right: 0;
+}
+
+.dropdown--align-end:dir(rtl) .dropdown__menu {
+  left: 0;
+}
+
+.dropdown__label {
+  cursor: pointer;
+}
+
+.dropdown__menu {
+  position: absolute;
+}
+
 /*********************************** BOX ************************************/
 
 /**
diff --git a/frontend/css/utilities.css b/frontend/css/utilities.css
index acc9b28f8..fd3b36bfc 100644
--- a/frontend/css/utilities.css
+++ b/frontend/css/utilities.css
@@ -59,6 +59,33 @@
   width: 100%;
 }
 
+/**
+ * .close-on-click-away
+ *
+ * Creates an area behind <details> pop-up that, when clicked/touched, will
+ * inadvertently toggle the menu away.
+ */
+
+.close-on-click-away[open] > summary::before {
+  content: " ";
+
+  /* Position: fills FULL AREA of the screen */
+  position: fixed;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+
+  /* Display & Box model */
+  display: block;
+
+  /* Color */
+  background: transparent;
+
+  /* Other */
+  cursor: default;
+}
+
 
 /**
  * Don't use this class.
diff --git a/src/CreeDictionary/CreeDictionary/templates/CreeDictionary/components/header.html b/src/CreeDictionary/CreeDictionary/templates/CreeDictionary/components/header.html
index c9e4f6bf8..3aab3d337 100644
--- a/src/CreeDictionary/CreeDictionary/templates/CreeDictionary/components/header.html
+++ b/src/CreeDictionary/CreeDictionary/templates/CreeDictionary/components/header.html
@@ -14,7 +14,7 @@ <h1 class="branding__heading branding__title">{% orth 'itwêwina' %}</h1>
   </header>
 
   <nav class="top-bar__nav">
-    <details class="toggle-box toggle-box--with-menu language-selector">
+    <details class="language-selector toggle-box toggle-box--with-menu close-on-click-away">
       <summary id="language-selector__button" class="toggle-box__toggle" data-cy="language-selector"
                aria-haspopup="menu" tabindex="0">Show Cree words in… </summary>
       <div class="menu toggle-box__menu" aria-labelledby="language-selector__button">
diff --git a/src/CreeDictionary/CreeDictionary/templates/CreeDictionary/components/paradigm-label-switcher.html b/src/CreeDictionary/CreeDictionary/templates/CreeDictionary/components/paradigm-label-switcher.html
new file mode 100644
index 000000000..8ffc10532
--- /dev/null
+++ b/src/CreeDictionary/CreeDictionary/templates/CreeDictionary/components/paradigm-label-switcher.html
@@ -0,0 +1,40 @@
+{% spaceless %}
+
+  {% comment %}
+    The paradigm label switcher.
+
+    Parameters:
+      <none>
+
+    Example:
+      [ Show plain English labels ↓ ]
+      | Plain English labels        |
+      | Linguistic labels           |
+      | nêhiyawêwin labels          |
+      `-----------------------------'
+
+    Assumptions:
+      - `preferences` can be used to access the `paradigm_label` preference
+
+  {% endcomment %}
+
+  <div class="menu-bar menu-bar--align-end">
+    <details class="menu-bar__item dropdown dropdown--align-end close-on-click-away">
+      <summary class="dropdown__label" data-cy="open-paradigm-label-switcher">Show {{ preferences.paradigm_label.current_label }}</summary>
+      <div class="menu dropdown__menu" data-cy="paradigm-label-options">
+        <ul class="unbullet">
+          {% for value, label in preferences.paradigm_label.choices_with_labels %}
+          <li class="menu-choice{% if value == preferences.paradigm_label.current_choice %} menu-choice--selected{% endif %}">
+            <form method="POST" action="{% url "cree-dictionary-change-paradigm-label" %}">
+              <button type="submit" class="unbutton fill-width"
+                      name="paradigmlabel" value="{{ value }}">
+                <span class="menu-choice__label">{{ label|capfirst }}</span>
+              {% csrf_token %}
+              </button>
+            </form>
+           {% endfor %}
+        </ul>
+      </div>
+    </details>
+  </div>
+{% endspaceless %}
diff --git a/src/CreeDictionary/CreeDictionary/templates/CreeDictionary/word-detail.html b/src/CreeDictionary/CreeDictionary/templates/CreeDictionary/word-detail.html
index f291c38ef..59488fb6f 100644
--- a/src/CreeDictionary/CreeDictionary/templates/CreeDictionary/word-detail.html
+++ b/src/CreeDictionary/CreeDictionary/templates/CreeDictionary/word-detail.html
@@ -62,6 +62,11 @@ <h1 id="head" class="definition-title">
 
     <section id="paradigm">
       {% if paradigm %}
+        {% if user.is_authenticated %}
+          {# As of 2021-06-02, this feature is not ready for the general
+             public, so make it only visible to logged-in users #}
+          {% include './components/paradigm-label-switcher.html' %}
+        {% endif %}
         {% if paradigm.uses_pane_based_layout %}
           {% include './components/paradigm-with-panes.html' %}
         {% else %}
diff --git a/src/CreeDictionary/CreeDictionary/views.py b/src/CreeDictionary/CreeDictionary/views.py
index 833ece736..ac69d0c4a 100644
--- a/src/CreeDictionary/CreeDictionary/views.py
+++ b/src/CreeDictionary/CreeDictionary/views.py
@@ -22,7 +22,7 @@
 from CreeDictionary.shared import expensive
 from CreeDictionary.utils import ParadigmSize
 from crkeng.app.preferences import DisplayMode, ParadigmLabel
-from morphodict.preference import ChangePreferenceView
+from morphodict.preference.views import ChangePreferenceView
 
 from .utils import url_for_query
 
@@ -302,7 +302,7 @@ class ChangeDisplayMode(ChangePreferenceView):
     Sets the mode= cookie, which affects how search results are rendered.
     """
 
-    preference = DisplayMode
+    preference = DisplayMode  # type: ignore  # mypy can't deal with the decorator :/
 
 
 class ChangeParadigmLabelPreference(ChangePreferenceView):
@@ -311,7 +311,7 @@ class ChangeParadigmLabelPreference(ChangePreferenceView):
     PARADIGMS!
     """
 
-    preference = ParadigmLabel
+    preference = ParadigmLabel  # type: ignore  # mypy can't deal with the decorator :/
 
 
 ## Helper functions
diff --git a/src/crkeng/app/preferences.py b/src/crkeng/app/preferences.py
index ca9ef63d7..3f383b47b 100644
--- a/src/crkeng/app/preferences.py
+++ b/src/crkeng/app/preferences.py
@@ -2,37 +2,39 @@
 Preferences used in itwêwina, the Cree Intelligent Dictionary.
 """
 
-from morphodict.preference import Preference
+from morphodict.preference import register_preference
 
 
-class DisplayMode(Preference):
+@register_preference
+class DisplayMode:
     """
     As of 2021-04-14, "mode" is a coarse mechanism for affecting the display; there are
     plans for more fine-grained control over the display of, e.g., search results.
     """
 
     cookie_name = "mode"
-    choices = [
+    choices = {
         # Community-mode: uses emoji and hides inflectional class
-        "community",
+        "community": "Community mode",
         # Linguist-mode: always displays inflectional class (e.g., VTA-1, NA-3, IPJ, etc.)
-        "linguistic",
-    ]
+        "linguistic": "Linguistic mode",
+    }
     default = "community"
 
 
-class ParadigmLabel(Preference):
+@register_preference
+class ParadigmLabel:
     """
     What style labels should be used in the paradigm?
     """
 
     cookie_name = "paradigmlabel"
-    choices = [
+    choices = {
         # Plain English labels; e.g., I → You (one), Something is happening now
-        "english",
+        "english": "plain English labels",
         # (Short) linguistic labels; e.g., 1Sg → 2Sg, Present Tense
-        "linguistic",
+        "linguistic": "linguistic labels",
         # nêhiyawêwin labels; e.g., niya → kiya, mêkwâc
-        "nehiyawewin",
-    ]
+        "nehiyawewin": "nêhiyawêwin labels",
+    }
     default = "english"
diff --git a/src/morphodict/preference/__init__.py b/src/morphodict/preference/__init__.py
new file mode 100644
index 000000000..71d969ec5
--- /dev/null
+++ b/src/morphodict/preference/__init__.py
@@ -0,0 +1,123 @@
+"""
+Preferences framework.
+
+Allows for generic site-wide preferences stored in cookies.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from functools import cache
+
+from django.http import HttpRequest
+from django.template import Context
+from django.utils.text import camel_case_to_spaces
+
+
+class PreferenceConfigurationError(Exception):
+    """
+    Raised when a preference is not configured properly.
+    Client code should probably NOT catch this.
+    """
+
+
+@dataclass
+class Preference:
+    """
+    A user preference, usually for the display of content on the website.
+    """
+
+    # camel case named, used within the code
+    name: str
+
+    # A mapping of all possible choices for this preference,
+    # to user-readable labels.
+    # {
+    #     "internalname": "user-readable label"
+    # }
+    choices: dict[str, str]
+
+    # Which one of the choices is the default
+    default: str
+
+    # Name used for the cookie
+    cookie_name: str
+
+    def current_value_from_template_context(self, context: Context) -> str:
+        if hasattr(context, "request"):
+            return self.current_value_from_request(context.request)
+        else:
+            return self.default
+
+    def current_value_from_request(self, request: HttpRequest) -> str:
+        return request.COOKIES.get(self.cookie_name, self.default)
+
+
+def all_preferences():
+    """
+    Return all preferences registered in this site.
+    """
+    return _registry().items()
+
+
+def register_preference(declaration) -> Preference:
+    """
+    Keep track of a preference in the currently running site.
+
+    Usage:
+
+        @register_preference
+        class MyPreference:
+            choices = {"pref1": "Preference 1", "pref2", "Preference two"}
+            default = "pref1"
+
+            name = "my_preference"  # optional; inferred from class name
+            cookie_name = "mypref"  # optional: inferred from name
+    """
+
+    if isinstance(declaration, type):
+        name = _snake_case_name_from_class(declaration)
+    else:
+        raise NotImplementedError
+
+    # Validate the preference
+    try:
+        choices = dict(declaration.choices)  # type: ignore
+    except AttributeError:
+        raise PreferenceConfigurationError(
+            "declaration must declare a dictionary of choices"
+        )
+
+    try:
+        default = declaration.default  # type: ignore
+    except AttributeError:
+        raise PreferenceConfigurationError("Preference MUST declare a default")
+
+    if default not in choices:
+        raise PreferenceConfigurationError(
+            f"Default does not exist in preference's choices: " f"{default=} {choices=}"
+        )
+
+    try:
+        cookie_name = declaration.cookie_name  # type: ignore
+    except AttributeError:
+        cookie_name = name
+
+    pref = Preference(
+        name=name, choices=choices, default=default, cookie_name=cookie_name
+    )
+
+    _registry()[name] = pref
+
+    return pref
+
+
+@cache
+def _registry() -> dict[str, Preference]:
+    """
+    Contains all registered preferences.
+    """
+    return {}
+
+
+def _snake_case_name_from_class(cls: type):
+    return camel_case_to_spaces(cls.__name__).replace(" ", "_")
diff --git a/src/morphodict/preference/context_processors.py b/src/morphodict/preference/context_processors.py
new file mode 100644
index 000000000..3c6695045
--- /dev/null
+++ b/src/morphodict/preference/context_processors.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from django.http import HttpRequest
+
+from morphodict.preference import Preference, all_preferences
+
+
+def preferences(request):
+    """
+    If this context processor is enabled, every `RequestContext` will contain this
+    variable:
+
+     - `preferences`, which allows you to access all preferences
+
+    In the following examples, replace <preference> with the name of the desired
+    preference in snake_case.
+
+    Iterate over every choice with its label:
+
+        {% for choice, label in preferences.<preference>.choices_with_labels }}
+
+    Get the request's current choice (internal value) for a preference:
+
+        {{ preferences.<preference>.current_choice }}
+
+    Get the request's current choice (user-facing) label for a preference:
+
+        {{ preference.<preference>.current_label }}
+    """
+    return {"preferences": _PreferenceContextProcessor(request)}
+
+
+class _PreferenceContextProcessor:
+    """
+    Makes preferences available in the template context.
+    """
+
+    def __init__(self, request: HttpRequest):
+        self._request = request
+        self._preferences = dict(all_preferences())
+
+    def __getattr__(self, name: str) -> _PreferenceInfo:
+        if pref := self._preferences.get(name):
+            return _PreferenceInfo(pref, self._request)
+        raise AttributeError(name)
+
+
+class _PreferenceInfo:
+    """
+    Exposes certain info about a Preference to the template context.
+    """
+
+    def __init__(self, preference: Preference, request: HttpRequest):
+        self._preference = preference
+        self._request = request
+
+    @property
+    def choices_with_labels(self):
+        """
+        Returns an iterable of (choice, label) pairs for the current preference.
+        The pairs are returned in declaration order in the original Preference subclass.
+        """
+        return self._preference.choices.items()
+
+    @property
+    def current_choice(self) -> str:
+        """
+        Returns the current choice (internal value) for a preference, given the
+        current request.
+        """
+        return self._preference.current_value_from_request(self._request)
+
+    @property
+    def current_label(self) -> str:
+        """
+        Returns the current label (user-facing) for a preference, given the
+        current request.
+        """
+        return self._preference.choices[self.current_choice]
diff --git a/src/morphodict/preference.py b/src/morphodict/preference/views.py
similarity index 90%
rename from src/morphodict/preference.py
rename to src/morphodict/preference/views.py
index 09fcda021..91d45e228 100644
--- a/src/morphodict/preference.py
+++ b/src/morphodict/preference/views.py
@@ -1,19 +1,9 @@
-"""
-Preferences framework.
-
-Allows for generic site-wide preferences stored in cookies.
-"""
 from http import HTTPStatus
-from typing import Type
 
 from django.http import HttpResponse
 from django.views import View
 
-
-class Preference:
-    cookie_name: str
-    choices: list[str]
-    default: str
+from morphodict.preference import Preference
 
 
 class ChangePreferenceView(View):
@@ -47,7 +37,7 @@ class ChangeMyPreference(ChangePreferenceView):
     See also: https://docs.djangoproject.com/en/3.2/topics/class-based-views/
     """
 
-    preference: Type[Preference]
+    preference: Preference
 
     @property
     def cookie_name(self):
diff --git a/src/morphodict/site/settings.py b/src/morphodict/site/settings.py
index 735c1bdab..89fdaf0ff 100644
--- a/src/morphodict/site/settings.py
+++ b/src/morphodict/site/settings.py
@@ -108,6 +108,7 @@
                 "django.contrib.messages.context_processors.messages",
                 "CreeDictionary.CreeDictionary.context_processors.display_options",
                 "morphodict.lexicon.context_processors.language_pair",
+                "morphodict.preference.context_processors.preferences",
             ]
         },
     }


Test patch:
diff --git a/src/morphodict/preference/test_context_processors.py b/src/morphodict/preference/test_context_processors.py
new file mode 100644
index 000000000..9321eeb97
--- /dev/null
+++ b/src/morphodict/preference/test_context_processors.py
@@ -0,0 +1,79 @@
+import pytest
+from django.http import HttpRequest
+from django.template import RequestContext, Template
+from pytest_django.asserts import assertHTMLEqual
+
+from morphodict.preference import register_preference
+
+
+@pytest.fixture(autouse=True)
+def ensure_context_processors_are_enabled(settings):
+    desired_processor = "morphodict.preference.context_processors.preferences"
+    settings.TEMPLATES = [
+        {
+            "BACKEND": "django.template.backends.django.DjangoTemplates",
+            "DIRS": [],
+            "APP_DIRS": True,
+            "OPTIONS": {"context_processors": [desired_processor]},
+        }
+    ]
+
+
+def test_can_access_choice_and_labels_in_template():
+    @register_preference
+    class CheesePizza:
+        choices = {
+            "mozza": "mozzarella",
+            "ricotta": "Ricotta",
+            "vegan": "Daiya",
+            "none": "no cheese",
+        }
+        default = "none"
+
+    request = HttpRequest()
+    empty_context = RequestContext(request, {})
+    template = Template(
+        """
+    {% for choice, label in preferences.cheese_pizza.choices_with_labels %}
+        <option value="{{ choice }}">{{ label|title }}</option>
+    {% endfor %}
+    """
+    )
+
+    html = template.render(empty_context)
+
+    assertHTMLEqual(
+        html,
+        """
+        <option value="mozza">Mozzarella</option>
+        <option value="ricotta">Ricotta</option>
+        <option value="vegan">Daiya</option>
+        <option value="none">No Cheese</option>
+    """,
+    )
+
+
+@pytest.mark.parametrize(
+    "choice,label", [(None, "cats"), ("cat", "cats"), ("dog", "dogs")]
+)
+def test_choice_and_label_from_request_context(choice, label):
+    @register_preference
+    class Pets:
+        cookie_name = "pet"
+        choices = {"cat": "cats", "dog": "dogs"}
+        default = "cat"  # Debatable
+
+    assert choice is None or choice in Pets.choices
+
+    request = HttpRequest()
+    if choice is not None:
+        request.COOKIES[Pets.cookie_name] = choice
+    context_that_likes_label = RequestContext(request, {})
+    template = Template(
+        """
+        I like {{ preferences.pets.current_label }}.
+    """
+    )
+    rendered = template.render(context_that_likes_label)
+
+    assert rendered.strip() == f"I like {label}."
diff --git a/src/morphodict/preference/test_preference.py b/src/morphodict/preference/test_preference.py
new file mode 100644
index 000000000..9a4559bba
--- /dev/null
+++ b/src/morphodict/preference/test_preference.py
@@ -0,0 +1,23 @@
+import pytest
+
+from morphodict.preference import (
+    PreferenceConfigurationError,
+    all_preferences, register_preference,
+)
+
+
+def test_create_preference_with_incorrect_default():
+    """
+    Ensure that a preference MUST have a valid default.
+    """
+    num_prefs_before = len(all_preferences())
+
+    with pytest.raises(PreferenceConfigurationError):
+
+        @register_preference
+        class PreferenceWithBadDefault:
+            choices = {"coffee": "Coffee", "tea": "Tea"}
+            default = "water"
+
+    num_prefs_after = len(all_preferences())
+    assert num_prefs_before == num_prefs_after