mirror of
https://github.com/django-components/django-components.git
synced 2025-08-03 13:58:16 +00:00
Add slots to components that can be filled when using component.
This commit is contained in:
parent
f6d2c5e003
commit
d1405dda13
6 changed files with 316 additions and 13 deletions
57
README.md
57
README.md
|
@ -2,16 +2,19 @@
|
|||
<a href="https://travis-ci.org/EmilStenstrom/django-components"><img align="right" src="https://travis-ci.org/EmilStenstrom/django-components.svg?branch=master"></a>
|
||||
A way to create simple reusable template components in Django.
|
||||
|
||||
# Compatiblity
|
||||
It lets you create "template components", that contains both the template, the Javascript and the CSS needed to generate the front end code you need for a modern app. Components look like this:
|
||||
|
||||
| Python version | Django version |
|
||||
|----------------|--------------------------|
|
||||
| 2.7 | 1.11 |
|
||||
| 3.4 | 1.11, 2.0 |
|
||||
| 3.5 | 1.11, 2.0, 2.1, 2.2 |
|
||||
| 3.6 | 1.11, 2.0, 2.1, 2.2, 3.0 |
|
||||
| 3.7 | 1.11, 2.0, 2.1, 2.2, 3.0 |
|
||||
| 3.8 | 1.11, 2.0, 2.1, 2.2, 3.0 |
|
||||
```htmldjango
|
||||
{% component name="calendar" date="2015-06-19" %}
|
||||
```
|
||||
|
||||
And this is what gets rendered (plus the CSS and Javascript you've specified):
|
||||
|
||||
```html
|
||||
<div class="calendar-component">Today's date is <span>2015-06-19</span></div>
|
||||
```
|
||||
|
||||
Read on to learn about the details!
|
||||
|
||||
# Installation
|
||||
|
||||
|
@ -49,6 +52,17 @@ TEMPLATES = [
|
|||
]
|
||||
```
|
||||
|
||||
# Compatiblity
|
||||
|
||||
| Python version | Django version |
|
||||
|----------------|--------------------------|
|
||||
| 2.7 | 1.11 |
|
||||
| 3.4 | 1.11, 2.0 |
|
||||
| 3.5 | 1.11, 2.0, 2.1, 2.2 |
|
||||
| 3.6 | 1.11, 2.0, 2.1, 2.2, 3.0 |
|
||||
| 3.7 | 1.11, 2.0, 2.1, 2.2, 3.0 |
|
||||
| 3.8 | 1.11, 2.0, 2.1, 2.2, 3.0 |
|
||||
|
||||
# Create your first component
|
||||
|
||||
A component in django-components is the combination of four things: CSS, Javascript, a Django template, and some Python code to put them all together.
|
||||
|
@ -136,6 +150,31 @@ The output from the above template will be:
|
|||
|
||||
This makes it possible to organize your front-end around reusable components. Instead of relying on template tags and keeping your CSS and Javascript in the static directory.
|
||||
|
||||
# Using slots in templates
|
||||
|
||||
Components support something called slots. They work a lot like Django blocks, but only inside components you define. Let's update our calendar component to support more customization, by updating our calendar.html template:
|
||||
|
||||
```htmldjango
|
||||
<div class="calendar-component">
|
||||
<div class="header">
|
||||
{% slot "header" %}Calendar header{% endslot %}
|
||||
</div>
|
||||
<div class="body">
|
||||
{% slot "body" %}Calendar body{% endslot %}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
When using the component, you specify what slots you want to fill and where you want to use the defaults from the template. It looks like this:
|
||||
|
||||
```
|
||||
{% component_block %}
|
||||
{% slot "body" %}Today's date is <span>{{ date }}</span>{% endslot %}
|
||||
{% endcomponent_block %}
|
||||
```
|
||||
|
||||
Since to header block is unspecified, it's taken from the base template.
|
||||
|
||||
# Running the tests
|
||||
|
||||
To quickly run the tests install the local dependencies by running
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
from inspect import getfullargspec
|
||||
|
||||
from django.forms.widgets import MediaDefiningClass
|
||||
from django.template.loader import render_to_string
|
||||
from django.template import Context
|
||||
from django.template.base import NodeList, TokenType, TextNode
|
||||
from django.template.loader import get_template
|
||||
from six import with_metaclass
|
||||
|
||||
# Allow "component.AlreadyRegistered" instead of having to import these everywhere
|
||||
|
@ -16,9 +20,44 @@ class Component(with_metaclass(MediaDefiningClass)):
|
|||
def render_dependencies(self):
|
||||
return self.media.render()
|
||||
|
||||
def render(self, *args, **kwargs):
|
||||
context = self.context(*args, **kwargs)
|
||||
return render_to_string(self.template(context), context)
|
||||
def slots_in_template(self, template):
|
||||
nodelist = NodeList()
|
||||
for node in template.template.nodelist:
|
||||
if (
|
||||
node.token.token_type == TokenType.BLOCK
|
||||
and node.token.split_contents()[0] == "slot"
|
||||
):
|
||||
nodelist.append(node)
|
||||
|
||||
return nodelist
|
||||
|
||||
def render(self, slots_filled=None, *args, **kwargs):
|
||||
slots_filled = slots_filled or []
|
||||
context_args_variables = getfullargspec(self.context).args[1:]
|
||||
context_args = {key: kwargs[key] for key in context_args_variables if key in kwargs}
|
||||
context = self.context(**context_args)
|
||||
template = get_template(self.template(context))
|
||||
slots_in_template = self.slots_in_template(template)
|
||||
|
||||
if slots_in_template:
|
||||
valid_slot_names = set([slot.name for slot in slots_in_template])
|
||||
nodelist = NodeList()
|
||||
for node in template.template.nodelist:
|
||||
if (
|
||||
node.token.token_type == TokenType.BLOCK
|
||||
and node.token.split_contents()[0] == "slot"
|
||||
):
|
||||
if node.name in valid_slot_names and node.name in slots_filled:
|
||||
nodelist.append(TextNode(slots_filled[node.name]))
|
||||
else:
|
||||
for node in node.nodelist:
|
||||
nodelist.append(node)
|
||||
else:
|
||||
nodelist.append(node)
|
||||
|
||||
return nodelist.render(Context(context))
|
||||
|
||||
return template.render(context)
|
||||
|
||||
class Media:
|
||||
css = {}
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.template.base import Node, NodeList, TokenType, TemplateSyntaxError, token_kwargs
|
||||
from django.template.library import parse_bits
|
||||
|
||||
from django_components.component import registry
|
||||
|
||||
register = template.Library()
|
||||
|
||||
COMPONENT_CONTEXT_KEY = "component_context"
|
||||
|
||||
|
||||
@register.simple_tag(name="component_dependencies")
|
||||
def component_dependencies_tag():
|
||||
unique_component_classes = set(registry.all().values())
|
||||
|
@ -16,8 +21,123 @@ def component_dependencies_tag():
|
|||
|
||||
return mark_safe("\n".join(out))
|
||||
|
||||
|
||||
@register.simple_tag(name="component")
|
||||
def component_tag(name, *args, **kwargs):
|
||||
component_class = registry.get(name)
|
||||
component = component_class()
|
||||
return component.render(*args, **kwargs)
|
||||
|
||||
|
||||
class SlotNode(Node):
|
||||
def __init__(self, name, nodelist, component=None):
|
||||
self.name, self.nodelist, self.component = name, nodelist, component
|
||||
|
||||
def __repr__(self):
|
||||
return "<Slot Node: %s. Contents: %r>" % (self.name, self.nodelist)
|
||||
|
||||
def render(self, context):
|
||||
if COMPONENT_CONTEXT_KEY not in context.render_context:
|
||||
context.render_context[COMPONENT_CONTEXT_KEY] = {}
|
||||
|
||||
if self.component not in context.render_context[COMPONENT_CONTEXT_KEY]:
|
||||
context.render_context[COMPONENT_CONTEXT_KEY][self.component] = {}
|
||||
|
||||
rendered_slot = self.nodelist.render(context)
|
||||
|
||||
if self.component:
|
||||
context.render_context[COMPONENT_CONTEXT_KEY][self.component][self.name] = rendered_slot
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
@register.tag("slot")
|
||||
def do_slot(parser, token, component=None):
|
||||
bits = token.split_contents()
|
||||
if len(bits) != 2:
|
||||
raise TemplateSyntaxError("'%s' tag takes only one argument" % bits[0])
|
||||
|
||||
slot_name = bits[1].strip('"')
|
||||
nodelist = parser.parse(parse_until=["endslot"])
|
||||
parser.delete_first_token()
|
||||
|
||||
return SlotNode(slot_name, nodelist, component=component)
|
||||
|
||||
|
||||
class ComponentNode(Node):
|
||||
def __init__(self, component, extra_context, slots):
|
||||
extra_context = extra_context or {}
|
||||
self.component, self.extra_context, self.slots = component, extra_context, slots
|
||||
|
||||
def __repr__(self):
|
||||
return "<Component Node: %s. Contents: %r>" % (self.component, self.slots)
|
||||
|
||||
def render(self, context):
|
||||
extra_context = {
|
||||
key: filter_expression.resolve(context)
|
||||
for key, filter_expression in self.extra_context.items()
|
||||
}
|
||||
context.update(extra_context)
|
||||
|
||||
self.slots.render(context)
|
||||
|
||||
if COMPONENT_CONTEXT_KEY in context.render_context:
|
||||
slots_filled = context.render_context[COMPONENT_CONTEXT_KEY][self.component]
|
||||
return self.component.render(slots_filled=slots_filled, **context.flatten())
|
||||
|
||||
return self.component.render()
|
||||
|
||||
|
||||
@register.tag("component_block")
|
||||
def do_component(parser, token):
|
||||
"""
|
||||
{% component_block "name" variable="value" variable2="value2" ... %}
|
||||
"""
|
||||
|
||||
bits = token.split_contents()
|
||||
tag_args, tag_kwargs = parse_bits(
|
||||
parser=parser,
|
||||
bits=bits,
|
||||
params=["tag_name", "component_name"],
|
||||
varargs=None,
|
||||
varkw=[],
|
||||
defaults=None,
|
||||
kwonly=[],
|
||||
kwonly_defaults=None,
|
||||
takes_context=False,
|
||||
name="component_block"
|
||||
)
|
||||
print(tag_args)
|
||||
tag_name = tag_args.pop(0)
|
||||
# assert False, tag_args[0]
|
||||
|
||||
if len(bits) < 2:
|
||||
raise TemplateSyntaxError("Call the '%s' tag with a component name as the first parameter" % tag_name)
|
||||
|
||||
component_name = bits[1]
|
||||
if not component_name.startswith(('"', "'")) or not component_name.endswith(('"', "'")):
|
||||
raise TemplateSyntaxError("Component name '%s' should be in quotes" % component_name)
|
||||
|
||||
component_name = component_name.strip('"')
|
||||
component_class = registry.get(component_name)
|
||||
component = component_class()
|
||||
|
||||
extra_context = {}
|
||||
if len(bits) > 2:
|
||||
extra_context = component.context(**token_kwargs(bits[2:], parser))
|
||||
|
||||
slots_filled = NodeList()
|
||||
tag_name = bits[0]
|
||||
while tag_name != "endcomponent_block":
|
||||
token = parser.next_token()
|
||||
if token.token_type != TokenType.BLOCK:
|
||||
continue
|
||||
|
||||
tag_name = token.split_contents()[0]
|
||||
|
||||
if tag_name == "slot":
|
||||
slots_filled += do_slot(parser, token, component=component)
|
||||
elif tag_name == "endcomponent_block":
|
||||
break
|
||||
|
||||
return ComponentNode(component, extra_context, slots_filled)
|
||||
|
|
6
tests/templates/slotted_template.html
Normal file
6
tests/templates/slotted_template.html
Normal file
|
@ -0,0 +1,6 @@
|
|||
{% load component_tags %}
|
||||
<custom-template>
|
||||
<header>{% slot header %}Default header{% endslot %}</header>
|
||||
<main>{% slot main %}Default main{% endslot %}</main>
|
||||
<footer>{% slot footer %}Default footer{% endslot %}</footer>
|
||||
</custom-template>
|
1
tests/templates/slotted_template_no_slots.html
Normal file
1
tests/templates/slotted_template_no_slots.html
Normal file
|
@ -0,0 +1 @@
|
|||
<custom-template></custom-template>
|
|
@ -22,10 +22,27 @@ class SimpleComponent(component.Component):
|
|||
css = {"all": ["style.css"]}
|
||||
js = ["script.js"]
|
||||
|
||||
|
||||
class IffedComponent(SimpleComponent):
|
||||
def template(self, context):
|
||||
return "iffed_template.html"
|
||||
|
||||
|
||||
class SlottedComponent(component.Component):
|
||||
def template(self, context):
|
||||
return "slotted_template.html"
|
||||
|
||||
class SlottedComponentNoSlots(component.Component):
|
||||
def template(self, context):
|
||||
return "slotted_template_no_slots.html"
|
||||
|
||||
class SlottedComponentWithContext(component.Component):
|
||||
def context(self, variable):
|
||||
return {"variable": variable}
|
||||
|
||||
def template(self, context):
|
||||
return "slotted_template.html"
|
||||
|
||||
class ComponentTemplateTagTest(SimpleTestCase):
|
||||
def setUp(self):
|
||||
# NOTE: component.registry is global, so need to clear before each test
|
||||
|
@ -75,3 +92,84 @@ class ComponentTemplateTagTest(SimpleTestCase):
|
|||
<link href="style.css" type="text/css" media="all" rel="stylesheet">
|
||||
<script type="text/javascript" src="script.js"></script>
|
||||
""").strip())
|
||||
|
||||
class ComponentSlottedTemplateTagTest(SimpleTestCase):
|
||||
def setUp(self):
|
||||
# NOTE: component.registry is global, so need to clear before each test
|
||||
component.registry.clear()
|
||||
|
||||
def test_slotted_template_basic(self):
|
||||
component.registry.register(name="test1", component=SlottedComponent)
|
||||
component.registry.register(name="test2", component=SimpleComponent)
|
||||
|
||||
template = Template("""
|
||||
{% load component_tags %}
|
||||
{% component_block "test1" %}
|
||||
{% slot "header" %}
|
||||
Custom header
|
||||
{% endslot %}
|
||||
{% slot "main" %}
|
||||
{% component "test2" variable="variable" %}
|
||||
{% endslot %}
|
||||
{% endcomponent_block %}
|
||||
""")
|
||||
rendered = template.render(Context({}))
|
||||
|
||||
self.assertHTMLEqual(rendered, """
|
||||
<custom-template>
|
||||
<header>Custom header</header>
|
||||
<main>Variable: <strong>variable</strong></main>
|
||||
<footer>Default footer</footer>
|
||||
</custom-template>
|
||||
""")
|
||||
|
||||
def test_slotted_template_with_context_var(self):
|
||||
component.registry.register(name="test1", component=SlottedComponentWithContext)
|
||||
|
||||
template = Template("""
|
||||
{% load component_tags %}
|
||||
{% with my_first_variable="test123" %}
|
||||
{% component_block "test1" variable="test456" %}
|
||||
{% slot "main" %}
|
||||
{{ my_first_variable }} - {{ variable }}
|
||||
{% endslot %}
|
||||
{% slot "footer" %}
|
||||
{{ my_second_variable }}
|
||||
{% endslot %}
|
||||
{% endcomponent_block %}
|
||||
{% endwith %}
|
||||
""")
|
||||
rendered = template.render(Context({"my_second_variable": "test321"}))
|
||||
|
||||
self.assertHTMLEqual(rendered, """
|
||||
<custom-template>
|
||||
<header>Default header</header>
|
||||
<main>test123 - test456</main>
|
||||
<footer>test321</footer>
|
||||
</custom-template>
|
||||
""")
|
||||
|
||||
def test_slotted_template_no_slots_filled(self):
|
||||
component.registry.register(name="test", component=SlottedComponent)
|
||||
|
||||
template = Template('{% load component_tags %}{% component_block "test" %}{% endcomponent_block %}')
|
||||
rendered = template.render(Context({}))
|
||||
|
||||
self.assertHTMLEqual(rendered, """
|
||||
<custom-template>
|
||||
<header>Default header</header>
|
||||
<main>Default main</main>
|
||||
<footer>Default footer</footer>
|
||||
</custom-template>
|
||||
""")
|
||||
|
||||
def test_slotted_template_without_slots(self):
|
||||
component.registry.register(name="test", component=SlottedComponentNoSlots)
|
||||
template = Template("""
|
||||
{% load component_tags %}
|
||||
{% component_block "test" %}{% endcomponent_block %}
|
||||
""")
|
||||
rendered = template.render(Context({}))
|
||||
|
||||
self.assertHTMLEqual(rendered, "<custom-template></custom-template>")
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue