diff --git a/docs/templating.md b/docs/templating.md new file mode 100644 index 0000000..4db9dbc --- /dev/null +++ b/docs/templating.md @@ -0,0 +1,28 @@ +--- +description: How to use custom template tags +--- + +# Template Tags + +Multiple custom template tags are provided by the library to make it easier to generate HTML attributes that interact with reflex. +These are `stimulus_controller`, `reflex` and `*_reflex` where `*` describes valid stimulus actions like _click_, _submit_, ... + +## The *_reflex tags + +The `*_reflex` tags can be used to connect a HTML attibute like the `` Tag to a reflex. Some syntax examples are + +``` +click me +click me + +``` + +where different tags are used for different _actions_. + +## The Syntax of the *_reflex tags + +There are three ways to pass data to the *_reflex tags: +1. Pass `controller` and `reflex` as list attributes and all parameters (`data-` attributes) as kvargs +2. Use the `*_reflex` tag inside a `stimulus_controlle` Block and only pass `reflex` (controller will be taken automatically from the `stimulus_controller`). Parameters work the same as above. +3. Use a `dict` as sole list argument which has to contain the keys `controller` and `reflex`. All other dict entries as well as all given kvargs are used as `data-` attributes. + diff --git a/sockpuppet/templatetags/sockpuppet.py b/sockpuppet/templatetags/sockpuppet.py index 737596c..10b0877 100644 --- a/sockpuppet/templatetags/sockpuppet.py +++ b/sockpuppet/templatetags/sockpuppet.py @@ -1,5 +1,8 @@ from django import template -from django.template import Template +from django.template import Template, RequestContext +from django.template.base import Token, VariableNode, FilterExpression +from django.utils.html import escape +from django.utils.safestring import mark_safe register = template.Library() @@ -25,7 +28,136 @@ def render(self, context): elif node.token.token_type.name == 'VAR': raw = '{{ ' + node.token.contents + ' }}' else: - msg ='{} is not yet handled'.format(node.token.token_type.name) + msg = '{} is not yet handled'.format(node.token.token_type.name) raise Exception(msg) output = output + raw return output + + +@register.simple_tag +def reflex(controller, **kwargs): + """ + Adds the necessary data-reflex tag to handle a click element on the respective element + :param controller: Name of the Reflex Controller and Method ({controller}#{handler}). + :param kwargs: Further data- attributes that should be passed to the handler + """ + # TODO Validate that the reflex is present and can be handled + return generate_reflex_attributes('click', controller, kwargs) + + +def generate_reflex_attributes(action, controller, parameters): + data = ' '.join([f'data-{key}="{val}"' for key, val in parameters.items()]) + return mark_safe(f'data-reflex="{action}->{controller}" {data}') + + +@register.tag +def stimulus_controller(parser, token, **kwargs): + _, controller = token.split_contents() + nodelist = parser.parse(('endcontroller',)) + parser.delete_first_token() + controller = controller.strip("'").strip('"') + return StimulusNode(controller, nodelist) + + +class ReflexNode(template.Node): + + def __init__(self, action, reflex, controller=None, parameters={}): + self.action = action + self.reflex = reflex + self.controller = controller + self.parameters = parameters + + def render(self, context: RequestContext): + # First, check if the "reflex" given is a VariableNode. We check to extract stuff from it + parameters = {} + if isinstance(self.reflex, VariableNode): + var_name = self.reflex.filter_expression.token + param = context.get(var_name) + + if param is None: + raise Exception(f"The given Variable '{var_name}' was not found in the context!") + + if 'controller' not in param or 'reflex' not in param: + raise Exception(f"The given object with name '{var_name}' needs to have attributes 'controller' and 'reflex'") + else: + self.controller = param['controller'] + self.reflex = param['reflex'] + parameters.update({k: param[k] for k in param.keys() if k not in ('controller', 'reflex')}) + if self.controller is None: + raise Exception( + "A ClickReflex tag can only be used inside a stimulus controller or needs an explicit controller set!") + + for k, v in self.parameters.items(): + if isinstance(v, VariableNode): + value = v.render(context) + else: + value = v + parameters.update({k: value}) + return generate_reflex_attributes(self.action, f'{self.controller}#{self.reflex}', parameters) + + +def extract_string_or_node(text): + stripped = text.strip("'").strip('"') + is_numeric = False + try: + int(stripped) + is_numeric = True + except: + pass + if text == stripped and not is_numeric: + return VariableNode(FilterExpression(text, parser=None)) + else: + return stripped + + +@register.tag("click_reflex") +def click_reflex(parser, token: Token): + controller, kwargs, reflex = parse_reflex_token(token) + return ReflexNode('click', reflex, controller=controller, parameters=kwargs) + + +@register.tag("submit_reflex") +def submit_reflex(parser, token: Token): + controller, kwargs, reflex = parse_reflex_token(token) + return ReflexNode('submit', reflex, controller=controller, parameters=kwargs) + + +@register.tag("input_reflex") +def submit_reflex(parser, token: Token): + controller, kwargs, reflex = parse_reflex_token(token) + return ReflexNode('input', reflex, controller=controller, parameters=kwargs) + + +def parse_reflex_token(token): + splitted = token.split_contents()[1:] + args = [] + kwargs = {} + for s in splitted: + if s.__contains__("="): + k, v = s.split("=") + kwargs.update({k: extract_string_or_node(v)}) + else: + args.append(extract_string_or_node(s)) + if len(args) == 1: + reflex = args[0] + controller = None + elif len(args) == 2: + controller = args[0] + reflex = args[1] + else: + raise Exception('Only one or two non-kv parameters can be given!') + return controller, kwargs, reflex + + +class StimulusNode(template.Node): + + def __init__(self, controller, nodelist): + self.controller = controller + self.nodelist = nodelist + + def render(self, context): + for node in self.nodelist: + if isinstance(node, ReflexNode): + node.controller = self.controller + output = self.nodelist.render(context) + return output diff --git a/tests/example/templates/second_tag_example.html b/tests/example/templates/second_tag_example.html new file mode 100644 index 0000000..51148a6 --- /dev/null +++ b/tests/example/templates/second_tag_example.html @@ -0,0 +1,6 @@ +{% load sockpuppet %} +{% stimulus_controller 'example_reflex' %} + click me +{% endcontroller %} + +I was done by object definition diff --git a/tests/example/templates/tag_example.html b/tests/example/templates/tag_example.html new file mode 100644 index 0000000..4771f31 --- /dev/null +++ b/tests/example/templates/tag_example.html @@ -0,0 +1,4 @@ +{% load sockpuppet %} +click me +click me + diff --git a/tests/example/urls.py b/tests/example/urls.py index 6b91f8d..5bdb0ea 100644 --- a/tests/example/urls.py +++ b/tests/example/urls.py @@ -16,9 +16,11 @@ from django.urls import path -from .views.example import ExampleView, ParamView +from .views.example import ExampleView, ParamView, TagExampleView, SecondTagExampleView urlpatterns = [ path('test/', ExampleView.as_view(), name='example'), - path('param/', ParamView.as_view(), name='param') + path('param/', ParamView.as_view(), name='param'), + path('tag/', TagExampleView.as_view(), name='tag'), + path('second/', SecondTagExampleView.as_view(), name='second_tag') ] diff --git a/tests/example/views/example.py b/tests/example/views/example.py index cccda4e..4c2b4cd 100644 --- a/tests/example/views/example.py +++ b/tests/example/views/example.py @@ -18,3 +18,24 @@ def get(self, request, *args, **kwargs): kwargs.update(dict(self.request.GET.items())) context = self.get_context_data(**kwargs) return self.render_to_response(context) + + +class TagExampleView(TemplateView): + template_name = 'tag_example.html' + + def get(self, request, *args, **kwargs): + kwargs.update({"parameter": "I am a parameter"}) + kwargs.update(dict(self.request.GET.items())) + context = self.get_context_data(**kwargs) + return self.render_to_response(context) + + +class SecondTagExampleView(TemplateView): + template_name = 'second_tag_example.html' + + def get(self, request, *args, **kwargs): + kwargs.update({"parameter": "I am a parameter"}) + kwargs.update({"object_definition": {"controller": "abc", "reflex": "increment", "other_param": 123}}) + kwargs.update(dict(self.request.GET.items())) + context = self.get_context_data(**kwargs) + return self.render_to_response(context) diff --git a/tests/test_tag.py b/tests/test_tag.py new file mode 100644 index 0000000..8e73d4f --- /dev/null +++ b/tests/test_tag.py @@ -0,0 +1,67 @@ +from django.template.response import TemplateResponse +from django.test import TestCase, Client + + +class TestTagSupport(TestCase): + + def test_click_reflex_tag(self): + c = Client() + response: TemplateResponse = c.get('/tag/') + + content = response.content.decode('utf-8') + + self.assertTrue(content.__contains__('' + 'click me' + '')) + + def test_submit_reflex_tag(self): + c = Client() + response: TemplateResponse = c.get('/tag/') + + content = response.content.decode('utf-8') + + self.assertTrue(content.__contains__('' + 'click me' + '')) + + def test_input_reflex_tag(self): + c = Client() + response: TemplateResponse = c.get('/tag/') + + content = response.content.decode('utf-8') + + self.assertTrue(content.__contains__( + '')) + + def test_reflex_tag_with_unsafe_input(self): + c = Client() + response: TemplateResponse = c.get('/tag/', data={"parameter": ""}) + + content = response.content.decode('utf-8') + + self.assertTrue(content.__contains__('' + 'click me' + '')) + + def test_controller_tag(self): + c = Client() + response: TemplateResponse = c.get('/second/', data={"parameter": ""}) + + content = response.content.decode('utf-8') + + self.assertTrue(content.__contains__( + 'click me')) + + def test_tag_by_dict(self): + c = Client() + response: TemplateResponse = c.get('/second/', data={"parameter": ""}) + + content = response.content.decode('utf-8') + + print(content) + + self.assertTrue(content.__contains__( + 'I was done by object definition'))