Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 1.0.1
## 1.0.3

1. Add component `Preview` support

## 1.0.2

1. Initial release
Binary file added docs/source/images/preview-index.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/source/images/small-modal-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ Topics
slot.md
templates.md
context.md
preview.md
testing.md
93 changes: 93 additions & 0 deletions docs/source/preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Previews

We can create previews for our components, and check the result in the browser, just like Storybook.

## Config

Add below config to your Django settings

```python
VIEW_COMPONENTS = {
"preview_base": ["django_app/tests/previews"],
}
```

`preview_base` is the base path for your previews. You can add multiple paths to it.

Add below url path to your Django urls

```python
path("previews/", include("django_viewcomponent.urls")),
```

So all the previews will work on the `/previews` path, I'd like to do that when `DEBUG` is `True`.

## Create a preview

```bash
django_app/tests/
├── previews
│   ├── modal_preview.py
│   └── tab_preview.py
```

Notes:

1. I'd like to put all previews under the `tests` directory, but you can put them anywhere you like.

```python
from django.template import Context, Template
from django_viewcomponent.preview import ViewComponentPreview


class ModalComponentPreview(ViewComponentPreview):
def small_modal(self, **kwargs):
template = Template(
"""
{% load viewcomponent_tags %}

{% component 'modal' size="sm" as component %}
{% call component.trigger %}
<button class="btn btn-blue" data-action="click->modal#open:prevent">Open Small Modal</button>
{% endcall %}

{% call component.body %}
<h2 class="mb-4 text-xl">Small Modal Content</h2>
<p class="mb-4">This is an example modal dialog box.</p>
{% endcall %}

{% call component.actions %}
<button class="btn btn-white" data-action="click->modal#close:prevent">Cancel</button>
<button class="btn btn-blue" data-action="click->modal#close:prevent">Close</button>
{% endcall %}
{% endcomponent %}
""",
)

return template.render(Context({}))
```

Notes:

1. We create a `ModalComponentPreview`, which inherits from `ViewComponentPreview`.
2. We defined a public method `small_modal`, which will be used to render the preview, and `small_modal` is the name of the preview.
3. We can get `request.GET` from the `kwargs` parameter, and use it to render the preview.
4. In most cases, we can create one preview class for one component, and create multiple public method for one preview.

That is it!

## Check the preview

If we check [http://127.0.0.1:8000/previews/](http://127.0.0.1:8000/previews/), all previews will be listed there.

![](./images/preview-index.png)

If we check the `small_modal` preview, we will see the result in the browser.

![](./images/small-modal-preview.png)

The great thing is, we can also see the source code of the preview, which helps us to understand how to use the component.

## Override Templates

You can also override the templates to fit your needs, please check the template files under `django_viewcomponent/templates/django_viewcomponent` to learn more.
11 changes: 9 additions & 2 deletions src/django_viewcomponent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@
from pathlib import Path

from django.template.engine import Engine

from django_viewcomponent.loaders import ComponentLoader


def autodiscover():
def autodiscover_components():
# Autodetect a <component>.py file in a components dir
current_engine = Engine.get_default()
loader = ComponentLoader(current_engine)
Expand All @@ -19,6 +18,14 @@ def autodiscover():
import_component_file(path)


def autodiscover_previews():
from django_viewcomponent.app_settings import app_settings
preview_base_ls = [Path(p) for p in app_settings.PREVIEW_BASE]
for directory in preview_base_ls:
for path in glob.iglob(str(directory / "**/*.py"), recursive=True):
import_component_file(path)


def import_component_file(path):
MODULE_PATH = path
MODULE_NAME = Path(path).stem
Expand Down
6 changes: 5 additions & 1 deletion src/django_viewcomponent/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@

class AppSettings:
def __init__(self):
self.settings = getattr(settings, "COMPONENTS", {})
self.settings = getattr(settings, "VIEW_COMPONENTS", {})

@property
def PREVIEW_BASE(self):
return self.settings.setdefault("preview_base", [])


app_settings = AppSettings()
4 changes: 2 additions & 2 deletions src/django_viewcomponent/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ class ComponentsConfig(AppConfig):
name = "django_viewcomponent"

def ready(self):
# autodiscover components
self.module.autodiscover()
self.module.autodiscover_components()
self.module.autodiscover_previews()
51 changes: 51 additions & 0 deletions src/django_viewcomponent/preview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import re
import inspect
from django.urls import reverse
from urllib.parse import urljoin

pattern = re.compile(r'(?<!^)(?=[A-Z])')


def public_instance_methods(cls):
return [name for name in cls.__dict__.keys() if not name.startswith('__') and callable(getattr(cls, name))]


def camel_to_snake(name):
# https://stackoverflow.com/questions/1175208/elegant-python-function-to-convert-camelcase-to-snake-case
name = pattern.sub('_', name).lower()
return name


class ViewComponentPreview:

previews = {}
preview_name = None
preview_view_component_path = None

def __init__(self, *args, **kwargs):
pass

def __init_subclass__(cls, **kwargs):
name = cls.__name__
name = name.replace("Preview", "")
new_name = camel_to_snake(name)
ViewComponentPreview.previews[new_name] = cls
cls.preview_name = new_name
cls.preview_view_component_path = inspect.getfile(cls)
cls.url = urljoin(reverse('django_viewcomponent:preview-index'), cls.preview_name + '/')

@classmethod
def examples(cls):
public_methods = public_instance_methods(cls)
return public_methods

def preview_source(self, method_name):
method = getattr(self, method_name)
raw_source_code = inspect.getsource(method)

# remove 4 spaces from the beginning of each line
lines = raw_source_code.split('\n')
modified_lines = [line[4:] for line in lines]
modified_string = '\n'.join(modified_lines)

return modified_string
16 changes: 16 additions & 0 deletions src/django_viewcomponent/templates/django_viewcomponent/base.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/themes/prism.min.css">
</head>
<body>
{% block content %}
{% endblock %}

<script src="https://cdn.jsdelivr.net/npm/[email protected]/prism.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/components/prism-python.min.js"></script>
</body>
</html>
14 changes: 14 additions & 0 deletions src/django_viewcomponent/templates/django_viewcomponent/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{% extends 'django_viewcomponent/base.html' %}

{% block content %}
{% for preview_name, preview in previews.items %}
<h3>
<a href="{{ preview.url}}">{{ preview_name }}</a>
</h3>
<ul>
{% for example in preview.examples %}
<li><a href="{{ preview.url}}{{ example }}">{{ example }}</a></li>
{% endfor %}
</ul>
{% endfor %}
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{% extends 'django_viewcomponent/base.html' %}

{% block content %}
{{ preview_html }}

<div class="view-component-source-example">
<h2>Source:</h2>
<pre><code class="language-python">{{ preview_source|safe }}</code></pre>
</div>

{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{% extends 'django_viewcomponent/base.html' %}

{% block content %}

<h3>
{{ preview.preview_name }}
</h3>
<ul>
{% for example in preview.examples %}
<li><a href="{{ preview.url}}{{ example }}">{{ example }}</a></li>
{% endfor %}
</ul>

{% endblock %}
13 changes: 13 additions & 0 deletions src/django_viewcomponent/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.urls import path

from .views import preview_index_view, previews_view, preview_view


app_name = "django_viewcomponent"


urlpatterns = [
path("", preview_index_view, name="preview-index"),
path('<preview_name>/', previews_view, name='previews'),
path('<preview_name>/<example_name>/', preview_view, name='preview'),
]
44 changes: 44 additions & 0 deletions src/django_viewcomponent/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from django_viewcomponent.preview import ViewComponentPreview
from django.shortcuts import render


def request_get_to_dict(request):
"""
Convert request.GET to a dictionary
"""
query_dict = request.GET
return {key: query_dict.getlist(key) if len(query_dict.getlist(key)) > 1 else query_dict.get(key) for key in query_dict.keys()}


def preview_index_view(request):
previews = ViewComponentPreview.previews
context = {
'previews': previews
}
return render(request, 'django_viewcomponent/index.html', context)


def previews_view(request, preview_name):
preview = ViewComponentPreview.previews[preview_name]
context = {
'preview': preview
}
return render(request, 'django_viewcomponent/previews.html', context)


def preview_view(request, preview_name, example_name):
preview_cls = ViewComponentPreview.previews[preview_name]
preview_instance = preview_cls()

query_dict = request_get_to_dict(request)
fun = getattr(preview_instance, example_name)
preview_html = fun(**query_dict)
preview_source = preview_instance.preview_source(example_name)

context = {
'preview_instance': preview_instance,
'preview_html': preview_html,
'preview_source': preview_source,
}

return render(request, 'django_viewcomponent/preview.html', context)
4 changes: 4 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ def pytest_configure():
"django_viewcomponent",
"tests.testapp",
],
ROOT_URLCONF="tests.testapp.urls",
VIEW_COMPONENTS={
"preview_base": ["previews"],
},
)


Expand Down
43 changes: 43 additions & 0 deletions tests/previews/simple_preview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from django_viewcomponent.preview import ViewComponentPreview
from django.template import Context, Template
from django_viewcomponent import component


class ExampleComponent(component.Component):
template = """
<span title="{{ self.title }}">
{{ self.content }}
</span>
"""

def __init__(self, **kwargs):
self.title = kwargs['title']


class SimpleExampleComponentPreview(ViewComponentPreview):

def with_title(self, title="default title", **kwargs):
return title

def with_component_call(self, title="default title", **kwargs):
"""
We can initialize the component and call render() method
"""
comp = ExampleComponent(title=title)
return comp.render(comp.get_context_data())

def with_template_render(self, title="default title", **kwargs):
"""
We can initialize the component in the template
"""
template = Template(
"""
{% load viewcomponent_tags %}

{% component "example" title=title %}
{% endcomponent %}
"""
)

# pass the title from the URL querystring to the context
return template.render(Context({"title": title}))
Loading