diff --git a/docs/recipes/api-rendering.md b/docs/recipes/api-rendering.md new file mode 100644 index 00000000..9617f96e --- /dev/null +++ b/docs/recipes/api-rendering.md @@ -0,0 +1,25 @@ +# API rendering + +For additional flexibility, django-pattern-library supports rendering patterns via an API endpoint. +This can be useful when implementing a custom UI while still using the pattern library’s Django rendering features. + +The API endpoint is available at `api/v1/render-pattern`. It accepts POST requests with a JSON payload containing the following fields: + +- `template_name` – the path of the template to render +- `config` – the configuration for the template, with the same data structure as the configuration files (`context` and `tags`). + +Here is an example, with curl: + +```bash +echo '{"template_name": "patterns/molecules/button/button.html", "config": {"context": {"target_page": {"title": "API"}}, "tags": {"pageurl":{"target_page":{"raw": "/hello-api"}}}}}' | curl -d @- http://localhost:8000/api/v1/render-pattern +``` + +The response will be the pattern’s rendered HTML: + +```html + + API + +``` + +Note compared to iframe rendering, this API always renders the pattern’s HTML standalone, never within a base template. diff --git a/mkdocs.yml b/mkdocs.yml index b5b06784..381b9992 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,7 @@ nav: - 'Inclusion tags': 'recipes/inclusion-tags.md' - 'Looping for tags': 'recipes/looping-for-tags.md' - 'Pagination': 'recipes/pagination.md' + - 'API rendering': 'recipes/api-rendering.md' - 'Reference': - 'API and settings': 'reference/api.md' - 'Concepts': 'reference/concepts.md' diff --git a/pattern_library/monkey_utils.py b/pattern_library/monkey_utils.py index 705fd4c1..d6ad07ba 100644 --- a/pattern_library/monkey_utils.py +++ b/pattern_library/monkey_utils.py @@ -2,11 +2,7 @@ from django.template.library import SimpleNode -from pattern_library.utils import ( - get_pattern_config, - is_pattern_library_context, - render_pattern, -) +from pattern_library.utils import is_pattern_library_context, render_pattern logger = logging.getLogger(__name__) UNSPECIFIED = object() @@ -31,9 +27,8 @@ def node_render(context): tag_overridden = False result = "" - # Load pattern's config - current_template_name = parser.origin.template_name - pattern_config = get_pattern_config(current_template_name) + # Get overriden tag config. + tag_overrides = context.get("__pattern_library_tag_overrides", {}) # Extract values for lookup from the token bits = token.split_contents() @@ -41,7 +36,7 @@ def node_render(context): arguments = " ".join(bits[1:]).strip() # Get config for a specific tag - tag_config = pattern_config.get("tags", {}).get(tag_name, {}) + tag_config = tag_overrides.get(tag_name, {}) if tag_config: # Get config for specific arguments tag_config = tag_config.get(arguments, {}) @@ -88,7 +83,7 @@ def node_render(context): logger.warning( 'No default or stub data defined for the "%s" tag in the "%s" template', tag_name, - current_template_name, + parser.origin.template_name, ) return original_node_render(context) diff --git a/pattern_library/urls.py b/pattern_library/urls.py index 1b9b51a3..4887dfe3 100644 --- a/pattern_library/urls.py +++ b/pattern_library/urls.py @@ -1,4 +1,4 @@ -from django.urls import re_path +from django.urls import path, re_path from pattern_library import get_pattern_template_suffix, views @@ -19,4 +19,6 @@ views.RenderPatternView.as_view(), name="render_pattern", ), + # API rendering + path("api/v1/render-pattern", views.render_pattern_api, name="render_pattern_api"), ] diff --git a/pattern_library/utils.py b/pattern_library/utils.py index 4e2856a1..b1a81b8d 100644 --- a/pattern_library/utils.py +++ b/pattern_library/utils.py @@ -210,12 +210,18 @@ def get_pattern_markdown(template_name): return markdown.markdown(f.read()) -def render_pattern(request, template_name, allow_non_patterns=False): +def render_pattern(request, template_name, allow_non_patterns=False, config=None): if not allow_non_patterns and not is_pattern(template_name): raise TemplateIsNotPattern - context = get_pattern_context(template_name) + if not config: + config = get_pattern_config(template_name) + + context = config.get("context", {}) + tags = config.get("tags", {}) + mark_context_strings_safe(context) context[get_pattern_context_var_name()] = True + context["__pattern_library_tag_overrides"] = tags for modifier in registry.get_for_template(template_name): modifier(context=context, request=request) return render_to_string(template_name, request=request, context=context) diff --git a/pattern_library/views.py b/pattern_library/views.py index 7003e5b2..4e3ab01d 100644 --- a/pattern_library/views.py +++ b/pattern_library/views.py @@ -1,8 +1,11 @@ +import json + from django.http import Http404, HttpResponse from django.template.loader import get_template from django.utils.decorators import method_decorator from django.utils.html import escape from django.views.decorators.clickjacking import xframe_options_sameorigin +from django.views.decorators.csrf import csrf_exempt from django.views.generic.base import TemplateView from pattern_library import get_base_template_names, get_pattern_base_template_name @@ -99,3 +102,19 @@ def get(self, request, pattern_template_name=None): return self.render_to_response(context) return HttpResponse(rendered_pattern) + + +@csrf_exempt +def render_pattern_api(request): + data = json.loads(request.body.decode("utf-8")) + template_name = data["template_name"] + config = data["config"] + + try: + rendered_pattern = render_pattern( + request, template_name, allow_non_patterns=False, config=config + ) + except TemplateIsNotPattern: + raise Http404 + + return HttpResponse(rendered_pattern) diff --git a/tests/templates/patterns/molecules/button/button.html b/tests/templates/patterns/molecules/button/button.html new file mode 100644 index 00000000..006451f3 --- /dev/null +++ b/tests/templates/patterns/molecules/button/button.html @@ -0,0 +1,4 @@ +{% load test_tags %} + + {% if label %}{{ label }}{% else %}{{ target_page.title }}{% endif %} + diff --git a/tests/templates/patterns/molecules/button/button.yaml b/tests/templates/patterns/molecules/button/button.yaml new file mode 100644 index 00000000..4546360f --- /dev/null +++ b/tests/templates/patterns/molecules/button/button.yaml @@ -0,0 +1,7 @@ +context: + target_page: + title: Get started +tags: + pageurl: + target_page: + raw: /get-started diff --git a/tests/templatetags/test_tags.py b/tests/templatetags/test_tags.py index 1326eb08..496935f6 100644 --- a/tests/templatetags/test_tags.py +++ b/tests/templatetags/test_tags.py @@ -27,6 +27,12 @@ def default_html_tag_falsey(arg=None): raise Exception("default_tag raised an exception") +@register.simple_tag() +def pageurl(page): + """Approximation of wagtail built-in tag for realistic example.""" + return "/page/url" + + # Get widget type of a field @register.filter(name="widget_type") def widget_type(bound_field): @@ -36,3 +42,4 @@ def widget_type(bound_field): override_tag(register, "error_tag") override_tag(register, "default_html_tag", default_html="https://potato.com") override_tag(register, "default_html_tag_falsey", default_html=None) +override_tag(register, "pageurl") diff --git a/tests/tests/test_context_modifiers.py b/tests/tests/test_context_modifiers.py index 6a646567..12ddd9f5 100644 --- a/tests/tests/test_context_modifiers.py +++ b/tests/tests/test_context_modifiers.py @@ -125,6 +125,7 @@ def test_applied_by_render_pattern(self, render_to_string): context={ "atom_var": "atom_var value from test_atom.yaml", "is_pattern_library": True, + "__pattern_library_tag_overrides": {}, "foo": "bar", "beep": "boop", }, diff --git a/tests/tests/test_views.py b/tests/tests/test_views.py index abd5ddb4..e8f9d00b 100644 --- a/tests/tests/test_views.py +++ b/tests/tests/test_views.py @@ -116,3 +116,32 @@ def test_fragment_extended_from_variable(self): ), "base content - extended content", ) + + +class APIViewsTestCase(SimpleTestCase): + def test_renders_with_tag_overrides(self): + api_endpoint = reverse("pattern_library:render_pattern_api") + response = self.client.post( + api_endpoint, + content_type="application/json", + data={ + "template_name": "patterns/molecules/button/button.html", + "config": { + "context": {"target_page": {"title": "API"}}, + "tags": {"pageurl": {"target_page": {"raw": "/hello-api"}}}, + }, + }, + ) + self.assertContains(response, "/hello-api") + + def test_404(self): + api_endpoint = reverse("pattern_library:render_pattern_api") + response = self.client.post( + api_endpoint, + content_type="application/json", + data={ + "template_name": "doesnotexist.html", + "config": {}, + }, + ) + self.assertEqual(response.status_code, 404)