-
-
Notifications
You must be signed in to change notification settings - Fork 76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: dependency injection with inject/provide #506
feat: dependency injection with inject/provide #506
Conversation
@EmilStenstrom @dylanjcastillo Let me know what you think of this one, thanks! |
Hey @EmilStenstrom @JuroOravec, a bit of an off-track comment: Sorry, I've been absent lately. I've been revising tasks and priorities for the next few months. I don't think I'll have time to contribute to django-components until the last quarter of the year. Once things settle down, I'll try to engage more actively. Until then, I'll probably be a passive supporter of the project. Thanks for understanding, and I look forward to getting back on track soon! |
@JuroOravec All this work and new concepts just because "isolated components are easier to maintain" :). I'm starting to doubt that all this work might be more work than having components break sometimes because the all use global scope... BUT, we do support isolated, and I think we should continue supporting it well. I think provide/inject looks complicated. The magic types version too. It looks like what we want is to "let a component switch to django context_behavior for some variables, and then have subcomponents explicitly declare they want those semi-global variables. Trying to think of a concrete example where this would be useful (please correct me if I'm wrong): {% component "table" rows=rows metadata=metadata %}
{% for row in rows %}
{% component "table_row" row=row %}
{% for cell in row.cells %}
{% component "table_cell" cell=cell %}
"{{ cell.data }}" from table {{ metadata.name }}
{% endcomponent %}
{% endfor %}
{% endcomponent %}
{% endfor %}
{% endcomponent %} With context_behavior="isolated" this wouldn't work, we would have to add metadata to both the table_row and table_cell components. But if the "table" component could provide a metadata block, the cell could inject it? |
Easier to maintain for the users, of course. Not necessarily the maintainers :) Again, this is an advanced feature, which is useful for either more complex cases, or for component library authors like me.
Yes, that's a good way to think about it, that I explicitly opt-in into the "django" context_behavior. Example 1 - LayoutingWhere I felt the need was when I was working with "layout" components. The
So I can use project_layout.html<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
...
</head>
<body>
<div class="header">
{% fill "header" %}
{% component "breadcrumbs" items=final_breadcrumbs %}{% endcomponent %}
{% endfill %}
</div>
<div class="sidebar">
{% component "bookmarks" bookmarks=final_bookmarks project_id=project.pk %}{% endcomponent %}
</div>
<div class="content">
{% component "navbar" @sidebar_toggle="toggleSidebar" %}{% endcomponent %}
{% slot "header" %}{% endslot %}
<div class="flex">
{# Split the content to 2 columns, based on whether `left_panel` slot is filled #}
{% if component_vars.is_filled.left_panel %}
<div class="w-1/3 h-12">
{% slot "left_panel" %}{% endslot %}
</div>
<div class="w-2/3 h-12 pl-8">
{% endif %}
{% slot "content" default %}{% endslot %}
{% if component_vars.is_filled.left_panel %}
</div>
{% endif %}
</div>
</div>
</body>
</html> project_layout.py@component.register("project_layout")
class ProjectLayout(Component):
template_name = "project_layout.html"
def setup_context(
self,
*_args,
project: Project,
breadcrumbs: list[Breadcrumb] | None = None,
bookmarks: list[Bookmark],
):
final_breadcrumbs = process_breadcrumbs(breadcrumbs or [])
final_bookmarks = process_bookmarks(bookmarks)
return {
"final_breadcrumbs": final_breadcrumbs,
"final_bookmarks": bookmarks,
"project": project,
}
The * Note: In my project, there's actually 2-3 layers of "layout" components, as 1 defines the HTML base with project.html{% component "project_layout"
project=project
bookmarks=bookmarks
breadcrumbs=breadcrumbs
%}
{% fill "header" %}
...
{% endfill %}
{% fill "left_panel" %}
...
{% endfill %}
{% fill "content" %}
...
{% endfill %}
{% endcomponent %} project.py@component.register("project")
class ProjectPage(Component):
template_name = "project.html"
def setup_context(
self,
*_args,
# Redacted
var1: list[Any],
var2: list[Any],
var3: dict[str, list[Any]],
var4: list[Any],
var5: list[Any],
# Used by project layout
bookmarks: list[Bookmark],
project: Project,
breadcrumbs: list[Any] | None = None,
**kwargs,
):
# pre-process here...
return {
"breadcrumbs": breadcrumbs or [],
"bookmarks": bookmarks,
"project": project,
"tab_items": tab_items,
"var1_processed": var1_processed,
"var2_processed": var2_processed,
} Currently, I have about 10 components that use
Example 2 - Configuring or styling component libraryThis is a consideration far down the line, but with provide/inject, I could write UI component library that would allow users to have a fine-grained control over the components, while still providing a clean/terse interface. This specifically is inspired by how Vuetify components work internally. And I guess React component libraries use the same principle with React's context provider. Let's consider the example you wrote, but without the {% component "table" rows=rows %}
{% for row in rows %}
{% component "table_row" row=row %}
{% for cell in row.cells %}
{% component "table_cell" cell=cell %}
"{{ cell.data }}" from table
{% endcomponent %}
{% endfor %}
{% endcomponent %}
{% endfor %}
{% endcomponent %} With the provide/inject feature, and taking inspiration from Vuetify, the snippet above could be condensed into: {% component "ui_table" rows=rows %}
{% component "ui_table_row" %}
{% component "ui_table_cell" %}
{% fill "cell" data="cell_data" %}
"{{ cell_data|safe }}"
{% endfill %}
{% endcomponent %}
{% endcomponent %}
{% endcomponent %} What we would achieve in the example above is that we would render a table, but wrap all it's cell contents with double quotes, so Note: I prefixed the component names with What happens above is the following:
The beauty of this approach is that if we want to stylize the component, or otherwise configure it, we need to configure only the E.g. in this example, the info about {% component "ui_table" rows=rows color="red" %}
{% component "ui_table_row" %}
{% component "ui_table_cell" %}
{% fill "cell" data="cell_data" %}
"{{ cell_data|safe }}"
{% endfill %}
{% endcomponent %}
{% endcomponent %}
{% endcomponent %} One could argue that "sure, but we could do the same with Django settings". But if we used a singular place for defining component styling, then all the instances of E.g. below, we have two tables, one which would be in red, and one in blue: {% component "ui_table" rows=rows color="red" %}
{% component "ui_table_row" %}
{% component "ui_table_cell" %}
{% fill "cell" data="cell_data" %}
"{{ cell_data|safe }}"
{% endfill %}
{% endcomponent %}
{% endcomponent %}
{% endcomponent %}
{% component "ui_table" rows=rows color="blue" %}
{% component "ui_table_row" %}
{% component "ui_table_cell" %}
{% fill "cell" data="cell_data" %}
"{{ cell_data|safe }}"
{% endfill %}
{% endcomponent %}
{% endcomponent %}
{% endcomponent %} Moreover, I won't go too much into it as I've already been writing this for too long, but with provide/inject, one could also define "sections" of the app with particular styling. See |
I'm talking about maintainers :) |
Great examples, thanks for sharing, and putting all that time into constructing them. I definitely see the need for this now. Now, for the syntax. I wonder if we could use kwargs again? |
@EmilStenstrom What do you have in mind with kwargs? Do you mean kwargs in templates, or inside API for "provide"So my initial approach was based on Vue. But for example in React it's more common to declare the "provided" state inside the template, e.g.: <MyCustomProvider myValue=myValue>
<!-- All components inside "MyCustomProvider" can inject "myValue" -->
</MyCustomProvider> So in Django that could look like this, if we defined a new tag {% provide my_value=my_value another_val=one %}
{# All components inside "provide" can inject "my_value" or "another_val" #}
{% endprovide %} In the Django example above, ALL kwargs that would be pased to Vue- vs React-based approachThe options being:
I don't have a preference in using one approach over the other. From my perspective, they are equal, as with both approaches you can achieve the same capabilities. E.g. what I initially preferred in Vue-inspired approach is that it makes it easy to transform data before the data is provided: class ParentComp(component.Component):
provide = ["abc"]
def get_context_data(self):
abc = some_crazy_transformation("abc")
return {
"abc": abc,
}
template = """
{% component "child" %}
{% endcomponent %}
""" But with React-style class ParentComp(component.Component):
def get_context_data(self):
abc = some_crazy_transformation("abc")
return {
"abc": abc,
}
template = """
{% provide abc=abc %}
{% component "child" %}
{% endcomponent %}
{% endprovide %}
""" OR making a "Provider" component: @component.register("my_provider")
class MyProvider(component.Component):
def get_context_data(self):
abc = some_crazy_transformation("abc")
return {
"abc": abc,
}
template = """
{% provide abc=abc %}
{% slot "content" default %}{% endslot %}
{% endprovide %}
"""
class ParentComp(component.Component):
template = """
{% component "my_provider" %}
{% component "child" %}
{% endcomponent %}
{% endcomponent %}
""" API for "inject"Injecting via attribute, magic type, or methodAs for "injecting" provided values, this one doesn't make sense to define in the template, as the point is to avoid having to use template, so data can be passed multiple levels deep. So I think that leaves us with 3 approaches:
At this point, I'm in favour of using the Providing/injecting per provider vs per keyInitially I liked the approach I outlined in the first comment, as with these, we don't care about WHO provided the value, but we care only about whether the key is there or not: {% provide my_value=my_value another_val=one %}
{% component "child" %}
{% endcomponent %}
{% endprovide %} class ChildComp(component.Component):
inject = ["my_value"]
def get_context_data(self, my_value):
print("injected my_value: ", my_value) # <-- Injected var in get_context_data
return {
"xyz": 2,
} Alternatively, we could take inspo from React, where you don't say what data you want, but WHO provided it. In that case we would also need to give a "name" property for the {% provide "my_context" my_value=my_value another_val=one %}
{% component "child" %}
{% endcomponent %}
{% endprovide %} And the injected value would then be a dictionary with all kwargs that were passed to the class ChildComp(component.Component):
inject = ["my_context"]
def get_context_data(self, my_context):
print("injected my_context: ", my_context) # <-- Injected var in get_context_data
# {"my_value": ..., "another_val": ...}
return {
"xyz": 2,
} Don't have a strong preference, here. First I liked the idea of providing data "per key", but it might become repetitive and look ugly with a lot of keys: class ChildComp(component.Component):
inject = ["abc", "some_thing", "another_thing", "oh_and_this", "some_more_ids", "another_id"]
def get_context_data(self, abc, some_thing, another_thing, oh_and_this, some_more_ids, another_id):
return {
"xyz": 2,
} On the other hand, with the "per provider" approach, we could keep things simpler (assuming that all the keys above are provided by only two providers: class ChildComp(component.Component):
inject = ["first_provider", "second_provider"]
def get_context_data(self, first_provider, second_provider):
return {
"xyz": 2,
} but it might also feel ugly if we then had to use dict keys to access the data from providers: class ChildComp(component.Component):
inject = ["first_provider", "second_provider"]
def get_context_data(self, first_provider, second_provider):
first_provider["abc"]
first_provider["some_thing"]
first_provider["another_thing"]
...
return {
"xyz": 2,
} So I'm considering whether to make it possible to access the keys as attributes: class ChildComp(component.Component):
inject = ["first_provider", "second_provider"]
def get_context_data(self, first_provider, second_provider):
first_provider.abc
first_provider.some_thing
first_provider.another_thing
...
return {
"xyz": 2,
} in which case the Actually, using a named tuple like that would be nice as it would have some level of immutability baked in (top-level keys immutable). As a reminder, the keys of the provided values MUST be valid identifiers. ConclusionTo sum up, personally I'd go with:
So overall it would look like so: Provide: {% provide "my_context" my_value=my_value another_val=one %}
{% component "child" %}
{% endcomponent %}
{% endprovide %} Inject: class ChildComp(component.Component):
def get_context_data(self):
my_context = self.inject("my_context")
return {
"abc": my_context.my_value,
"def": my_context.another_val,
} |
That was quite the read, so many options to consider!
Another option for inject is a decorator. You could specify which global variables to make available, and then access them as if they were passed as args to the get_context_data function. And since we don't use provide, maybe use @global to make it clear that we're accessing the global scope. class ChildComp(component.Component):
@global("abc")
def get_context_data(self, abc):
print("injected abc: ", abc) # <-- Injected var in get_context_data
return {
"xyz": 2,
}
template = """
abc: {{ abc }} <-- Injected var in template
xyz: {{ xyz }}
""" Thoughts on the {% with %}/@global combo? |
Lol, there's someone called @global, who was tagged 😆 (sorry) Also, thanks for the feedback & ideas!
Not sure. My take is that it would depend on how it would be implemented. But in theory, components in "django" mode should have access to the same (and more) variables, so it could work, yes.
|
@JuroOravec I'm convinced, let's do |
for more information, see https://pre-commit.ci
@EmilStenstrom Thanks for all your help! I've updated the implementation, added tests, and added a section to README, and it's now ready for review :) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very nicely done as usual. Found some small fixes, but nothing that blocks a release!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Inlining templates like these in the tests would make the tests much easier to follow.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure if we can inline this one (or rather, don't know how), as this file is intended to be used with the {% include %}
tag.
@EmilStenstrom Thanks again for all your input and help! I'm excited about this one! I've updated the docs/comments and added release notes. |
This is a feature proposal for what I called "Global rendering context" in #483. The name might not have been the best, so I'll describe here what I had in mind / what I'm missing.
Background
I had a look at Django's RequestContext as mentioned in #483 (comment), but I don't feel like it's matching my needs. In my project there are cases where it doesn't make much sense to pass the Request instance to the rendering context, so relying on Django's
context_processors
feels more like a workaround than a proper solution.Furthermore, I need to define different "globals" for different views (rendering runs). So again the approach with
context_processors
, which are defined globally in the settings, don't feel like a good fit.To be clear, what I mean by "globals" is that when I have some template with nested components, then, in the "isolated" mode, the inner nested components can access these "globals" without the "globals" being passed via props. So actually, what I want to address with this is the issue of "prop drilling"
Since I'm dealing with prop drilling, I again took inspiration from Vue's inject/provide and React's Context/Provide.
The idea is that I can mark some variables to be "provided", and then any descendant of that component can access said variables by "injecting" it into it's context.
The easiest way to achieve this is leveraging Django's Context. However, it would be useful to access the "injected" variables in both
get_context_data
AND the component's template. But we don't have access to the Context insideget_context_data
(which IMO is a good thing).So that's how I arrived at the initial suggestion below.
How it works:
provide
attribute to a list of variable names that we want to provide.get_context_data
, or an error is raised.inject
attribute to a list of variable names that we want to inject.get_context_data
must specify the argument in function signature, or use**kwargs
, or they get error.get_context_data
, but is also automatically made available inside the component template, without having to explicitly expose the variable viaget_context_data
.Example
Further notes
In theory the
provide
/inject
attributes could be skipped, and instead we could take an inspo from fastAPI and django-ninja, where they make use of inlined annotations to convey meaning. So the API would look like so:The upside of this approach that user specifies the variable name only once, whereas they have to specify it twice with the
provide=["abc"]
approach.However, I'm not yet familiar with how Python annotations like
Inject[int]
would work, so for simplicity's sake I'd go with the original approach for now.TODO