diff --git a/src/open_inwoner/components/templates/components/AnchorMenu/AnchorMenu.html b/src/open_inwoner/components/templates/components/AnchorMenu/AnchorMenu.html
index 319cf32d4c..ef68ee0cc9 100644
--- a/src/open_inwoner/components/templates/components/AnchorMenu/AnchorMenu.html
+++ b/src/open_inwoner/components/templates/components/AnchorMenu/AnchorMenu.html
@@ -8,6 +8,16 @@
{% for anchor in anchors %}
{% endfor %}
{{ contents }}
@@ -20,6 +30,16 @@
{% for anchor in anchors %}
{% endfor %}
{{ contents }}
diff --git a/src/open_inwoner/pdc/tests/test_product.py b/src/open_inwoner/pdc/tests/test_product.py
index 8d3cb32311..f4903b4299 100644
--- a/src/open_inwoner/pdc/tests/test_product.py
+++ b/src/open_inwoner/pdc/tests/test_product.py
@@ -254,3 +254,26 @@ def test_sidemenu_button_is_rendered_when_no_cta_inside_product_content(self):
self.assertTrue(sidemenu_cta_button)
self.assertIn(product.link, sidemenu_cta_button[0].values())
+
+
+@override_settings(ROOT_URLCONF="open_inwoner.cms.tests.urls")
+class TestProductDetailView(WebTest):
+ def test_subheadings_in_sidebar(self):
+ product = ProductFactory(
+ content="##First subheading\rlorem ipsum...\r##Second subheading",
+ link="http://www.example.com",
+ )
+
+ response = self.app.get(
+ reverse("products:product_detail", kwargs={"slug": product.slug})
+ )
+
+ links = response.pyquery(".anchor-menu__sublist").find("a")
+
+ self.assertEqual(len(links), 2)
+
+ self.assertEqual(links[0].text, "First subheading")
+ self.assertEqual(links[0].attrib["href"], "#first-subheading")
+
+ self.assertEqual(links[1].text, "Second subheading")
+ self.assertEqual(links[1].attrib["href"], "#second-subheading")
diff --git a/src/open_inwoner/pdc/utils.py b/src/open_inwoner/pdc/utils.py
index 33785cbe50..1e5c350733 100644
--- a/src/open_inwoner/pdc/utils.py
+++ b/src/open_inwoner/pdc/utils.py
@@ -1 +1,25 @@
+from django.utils.text import slugify
+
+import markdown
+from bs4 import BeautifulSoup
+
PRODUCT_PATH_NAME = "products"
+
+
+def extract_subheadings(content: str, tag: str) -> list[tuple[str, str]]:
+ """
+ :returns: a list of tuples containing a subheading (the text of the `tag` element)
+ and a slug for the corresponding HTML anchor
+ """
+ md = markdown.Markdown()
+ html_string = md.convert(content)
+
+ soup = BeautifulSoup(html_string, "html.parser")
+
+ subs = []
+ for tag in soup.find_all("h2"):
+ subheading = tag.text
+ slug = slugify(subheading)
+ subs.append((subheading, slug))
+
+ return subs
diff --git a/src/open_inwoner/pdc/views.py b/src/open_inwoner/pdc/views.py
index c64897f80a..04f06def51 100644
--- a/src/open_inwoner/pdc/views.py
+++ b/src/open_inwoner/pdc/views.py
@@ -9,13 +9,13 @@
from open_inwoner.configurations.models import SiteConfiguration
from open_inwoner.pdc.models.product import ProductCondition
-from open_inwoner.plans.models import Plan
from open_inwoner.questionnaire.models import QuestionnaireStep
from ..utils.views import CommonPageMixin
from .choices import YesNo
from .forms import ProductFinderForm
from .models import Category, Product, ProductLocation, Question
+from .utils import extract_subheadings
class CategoryBreadcrumbMixin:
@@ -185,8 +185,10 @@ def get_context_data(self, **kwargs):
product = self.get_object()
context = super().get_context_data(**kwargs)
+ subheadings = extract_subheadings(product.content, tag="h2")
+
anchors = [
- ("#title", product.name),
+ ("#title", product.name, subheadings),
]
if product.question_set.exists():
anchors.append(("#faq", _("Veelgestelde vragen")))
@@ -194,13 +196,8 @@ def get_context_data(self, **kwargs):
anchors.append(("#files", _("Bestanden")))
if product.locations.exists():
anchors.append(("#locations", _("Locaties")))
- if product.links.exists():
- anchors.append(("#links", _("Links")))
if product.contacts.exists():
anchors.append(("#contact", _("Contact")))
- if product.related_products.published().exists():
- anchors.append(("#see", _("Zie ook")))
- # anchors.append(("#share", _("Delen"))) disabled for #822
context["anchors"] = anchors
context["related_products_start"] = 6 if product.links.exists() else 1
diff --git a/src/open_inwoner/scss/components/Header/AnchorMenu.scss b/src/open_inwoner/scss/components/Header/AnchorMenu.scss
index 2a5d4f1ca1..451f25b00a 100644
--- a/src/open_inwoner/scss/components/Header/AnchorMenu.scss
+++ b/src/open_inwoner/scss/components/Header/AnchorMenu.scss
@@ -63,7 +63,7 @@
margin-left: var(--spacing-large);
}
- .link {
+ &__list-item {
box-sizing: border-box;
padding: var(--spacing-large) var(--spacing-medium);
@@ -82,6 +82,30 @@
}
}
+ // nested list
+ &__sublist {
+ list-style-type: '- ';
+ padding-left: var(--spacing-large);
+ padding-top: var(--spacing-large);
+ line-height: var(--spacing-large);
+
+ li {
+ border-left: none;
+ }
+ li:not(:last-child) {
+ margin-bottom: var(--spacing-large);
+ }
+ li > a {
+ font-size: small;
+ }
+
+ &--mobile {
+ li > a {
+ font-size: var(--font-size-heading-4) !important;
+ }
+ }
+ }
+
&--mobile__title {
box-sizing: border-box;
padding: var(--spacing-large) var(--spacing-large) var(--spacing-medium)
diff --git a/src/open_inwoner/utils/ckeditor.py b/src/open_inwoner/utils/ckeditor.py
index 2b0406bb97..16c117cbb0 100644
--- a/src/open_inwoner/utils/ckeditor.py
+++ b/src/open_inwoner/utils/ckeditor.py
@@ -1,3 +1,4 @@
+from django.utils.text import slugify
from django.utils.translation import gettext as _
import markdown
@@ -52,6 +53,9 @@ def get_product_rendered_content(product):
element.attrs["class"] = class_name
+ if tag == "h2":
+ element.attrs["id"] = slugify(element.text)
+
if "[CTABUTTON]" in element.text:
# decompose the element when product doesn't have either a link or a form
if not (product.link or product.form):