diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
new file mode 100644
index 0000000..fff6a34
--- /dev/null
+++ b/.github/workflows/python-publish.yml
@@ -0,0 +1,37 @@
+# This workflow will upload a Python Package using Twine when a release is created
+# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
+
+# This workflow uses actions that are not certified by GitHub.
+# They are provided by a third-party and are governed by
+# separate terms of service, privacy policy, and support
+# documentation.
+
+name: Upload Python Package
+
+on:
+ release:
+ types: [published]
+
+permissions:
+ contents: read
+
+jobs:
+ deploy:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python
+ uses: actions/setup-python@v3
+ with:
+ python-version: '3.10'
+ - name: Install dependencies
+ run: |
+ python -m pip install poetry
+ - name: Build package
+ run: poetry build
+ - name: Publish package
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.github/workflows/runtests.yml b/.github/workflows/runtests.yml
new file mode 100644
index 0000000..04bf1e3
--- /dev/null
+++ b/.github/workflows/runtests.yml
@@ -0,0 +1,33 @@
+name: Runs tests
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+jobs:
+ runtests:
+ runs-on: ubuntu-latest
+ env:
+ CHANNELS_REDIS: redis://localhost:6379/0
+ strategy:
+ matrix:
+ python-version: ['3.8', '3.9', '3.10' ]
+ services:
+ redis:
+ image: redis
+ ports:
+ - 6379:6379
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install dependencies
+ run: |
+ pip install -r requirements-dev.txt
+ - name: Run tests
+ run: |
+ tox
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2b874d6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,52 @@
+*.pyc
+*.egg
+*.egg-info
+
+docs/build
+dist
+build
+
+*.lock
+
+*.sqlite3
+*.db
+
+*.DS_Store
+
+.cache
+__pycache__
+.mypy_cache/
+.pytest_cache/
+.vscode/
+.coverage
+docs/build
+
+node_modules/
+
+*.bak
+
+logs
+*log
+npm-debug.log*
+
+# Translations
+# *.mo
+*.pot
+
+# Django media/static dirs
+media/
+static/dist/
+static/dev/
+
+.ipython/
+.env
+
+celerybeat.pid
+celerybeat-schedule
+
+# Common typos
+:w
+'
+.tox
+
+/venv/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..4abbf8c
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,24 @@
+repos:
+ - repo: https://github.com/pycqa/isort
+ rev: 5.12.0
+ hooks:
+ - id: isort
+ - repo: https://github.com/psf/black
+ rev: 23.10.1
+ hooks:
+ - id: black
+ - repo: https://github.com/PyCQA/flake8
+ rev: 6.0.0
+ hooks:
+ - id: flake8
+ additional_dependencies:
+ - flake8-bugbear
+ - flake8-comprehensions
+ - flake8-no-pep420
+ - flake8-print
+ - flake8-tidy-imports
+ - flake8-typing-imports
+ - repo: https://github.com/pre-commit/mirrors-mypy
+ rev: v1.6.1
+ hooks:
+ - id: mypy
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 0000000..f3f8f03
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,14 @@
+version: 2
+
+build:
+ os: ubuntu-22.04
+ tools:
+ python: "3.12"
+
+# Build documentation in the "docs/" directory with Sphinx
+sphinx:
+ configuration: docs/source/conf.py
+
+python:
+ install:
+ - requirements: docs/requirements.txt
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..5e03ee7
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,13 @@
+# Changelog
+
+## 1.0.5
+
+1. Add `show_previews` option to control whether to show previews or not
+
+## 1.0.4
+
+1. Add component `Preview` support
+
+## 1.0.2
+
+1. Initial release
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..ccf23c3
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,9 @@
+build:
+ poetry build
+
+publish:
+ poetry publish
+
+# poetry config repositories.testpypi https://test.pypi.org/legacy/
+publish-test:
+ poetry publish -r testpypi
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b073b51
--- /dev/null
+++ b/README.md
@@ -0,0 +1,22 @@
+# README
+
+[![PyPI version](https://badge.fury.io/py/django-viewcomponent.svg)](https://badge.fury.io/py/django-viewcomponent)
+[![PyPI Supported Python Versions](https://img.shields.io/pypi/pyversions/django-viewcomponent.svg)](https://pypi.python.org/pypi/django-viewcomponent/)
+
+django-viewcomponent is a Django library that provides a way to create reusable components for your Django project.
+
+It is inspired by Rails [ViewComponent](https://viewcomponent.org/).
+
+## Why use django-viewcomponent
+
+### Single responsibility
+
+django-viewcomponent can help developers to build reusable components from the Django templates, and make the templates more readable and maintainable.
+
+### Testing
+
+django-viewcomponent components are Python objects, so they can be **easily tested** without touching Django view and Django urls.
+
+## Documentation
+
+[Documentation](https://django-viewcomponent.readthedocs.io/en/latest/)
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..d0c3cbf
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS ?=
+SPHINXBUILD ?= sphinx-build
+SOURCEDIR = source
+BUILDDIR = build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 0000000..747ffb7
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=source
+set BUILDDIR=build
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.https://www.sphinx-doc.org/
+ exit /b 1
+)
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 0000000..de8c25c
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,2 @@
+furo==2023.9.10
+myst-parser
diff --git a/docs/source/conf.py b/docs/source/conf.py
new file mode 100644
index 0000000..cfaefae
--- /dev/null
+++ b/docs/source/conf.py
@@ -0,0 +1,63 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# For the full list of built-in configuration values, see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# -- Project information -----------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
+
+import datetime
+import sys
+import tomllib
+from pathlib import Path
+
+here = Path(__file__).parent.resolve()
+sys.path.insert(0, str(here / ".." / ".." / "src"))
+
+
+# -- Project information -----------------------------------------------------
+project = "django-viewcomponent"
+copyright = f"{datetime.datetime.now().year}, Michael Yin"
+author = "Michael Yin"
+
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+# The short X.Y version.
+
+
+def _get_version() -> str:
+ with (here / ".." / ".." / "pyproject.toml").open("rb") as fp:
+ data = tomllib.load(fp)
+ version: str = data["tool"]["poetry"]["version"]
+ return version
+
+
+version = _get_version()
+# The full version, including alpha/beta/rc tags.
+release = version
+
+# -- General configuration ---------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
+
+extensions = ["sphinx.ext.autodoc", "myst_parser"]
+
+source_suffix = {
+ ".rst": "restructuredtext",
+ ".txt": "markdown",
+ ".md": "markdown",
+}
+
+templates_path = ["_templates"]
+exclude_patterns = [] # type: ignore
+
+
+# -- Options for HTML output -------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
+
+# html_theme = 'alabaster'
+# html_static_path = ['_static']
+html_theme = "furo"
+pygments_style = "sphinx"
diff --git a/docs/source/context.md b/docs/source/context.md
new file mode 100644
index 0000000..c0df29c
--- /dev/null
+++ b/docs/source/context.md
@@ -0,0 +1,18 @@
+# Context
+
+In Django view, developers can add extra variable to the context via `get_context_data` method.
+
+In the same way, we can add extra variable to the component context via `get_context_data` method.
+
+```python
+def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["var2"] = "value2"
+ return context
+```
+
+Then you can get the `var2` in the component template via `{{ var2 }}`, just like other variables.
+
+## Self
+
+`self` points to the component instance itself, since each component has its own context, so each time the component is rendered, `self` is overwritten, and this would not cause any conflict.
diff --git a/docs/source/getting_started.md b/docs/source/getting_started.md
new file mode 100644
index 0000000..cd98772
--- /dev/null
+++ b/docs/source/getting_started.md
@@ -0,0 +1,61 @@
+# Getting Started
+
+Create *components/example/example.py*
+
+```python
+from django_viewcomponent import component
+
+
+@component.register("example")
+class ExampleComponent(component.Component):
+ template_name = "example/example.html"
+
+ def __init__(self, **kwargs):
+ self.title = kwargs['title']
+```
+
+1. `template_name` is a string that contains the path to the HTML template for the component.
+
+Create *components/example/example.html*
+
+```django
+
+ {{ self.content }}
+
+```
+
+Notes:
+
+1. This is the template file for the component.
+2. `self` points to the component instance itself, we can use it to access component variable and property.
+
+Then the file structure would look like this:
+
+```bash
+components
+├── example
+│ ├── example.html
+│ └── example.py
+└── hello
+ └── hello.py
+```
+
+To use the component in Django templates:
+
+```django
+{% component "example" title='my title' %}
+ Hello World
+{% endcomponent %}
+```
+
+Notes:
+
+1. The **children node list** within the `component` tag will be evaluated first, passed to the component, and it can be accessed via `{{ self.content }}` in the component template.
+
+So the final HTML would be:
+
+```html
+
+ Hello World
+
+```
diff --git a/docs/source/images/preview-index.png b/docs/source/images/preview-index.png
new file mode 100644
index 0000000..13847c4
Binary files /dev/null and b/docs/source/images/preview-index.png differ
diff --git a/docs/source/images/small-modal-preview.png b/docs/source/images/small-modal-preview.png
new file mode 100644
index 0000000..da35c19
Binary files /dev/null and b/docs/source/images/small-modal-preview.png differ
diff --git a/docs/source/index.rst b/docs/source/index.rst
new file mode 100644
index 0000000..845b433
--- /dev/null
+++ b/docs/source/index.rst
@@ -0,0 +1,19 @@
+django-viewcomponent
+=====================
+
+A simple way to create reusable components in Django, inspired by Rails' ViewComponent.
+
+Topics
+------
+
+.. toctree::
+ :maxdepth: 2
+
+ install.md
+ overview.md
+ getting_started.md
+ slot.md
+ templates.md
+ context.md
+ preview.md
+ testing.md
diff --git a/docs/source/install.md b/docs/source/install.md
new file mode 100644
index 0000000..1d7383c
--- /dev/null
+++ b/docs/source/install.md
@@ -0,0 +1,57 @@
+# Installation
+
+```shell
+$ pip install django-viewcomponent
+```
+
+Then add the app into `INSTALLED_APPS` in settings.py
+
+```python
+INSTALLED_APPS = [
+ ...,
+ 'django_viewcomponent',
+]
+```
+
+Modify `TEMPLATES` section of settings.py as follows:
+
+1. Remove `'APP_DIRS': True,`
+2. add `loaders` to `OPTIONS` list and set it to following value:
+
+```python
+TEMPLATES = [
+ {
+ ...,
+ 'OPTIONS': {
+ 'context_processors': [
+ ...
+ ],
+ 'loaders':[(
+ 'django.template.loaders.cached.Loader', [
+ 'django.template.loaders.filesystem.Loader',
+ 'django.template.loaders.app_directories.Loader',
+ 'django_viewcomponent.loaders.ComponentLoader',
+ ]
+ )],
+ },
+ },
+]
+```
+
+(**Optional**) To avoid loading the app in each template using ``` {% load viewcomponent_tags %} ```, you can add the tag as a 'builtin' in settings.py
+
+```python
+TEMPLATES = [
+ {
+ ...,
+ 'OPTIONS': {
+ 'context_processors': [
+ ...
+ ],
+ 'builtins': [
+ 'django_viewcomponent.templatetags.viewcomponent_tags', # new
+ ]
+ },
+ },
+]
+```
diff --git a/docs/source/overview.md b/docs/source/overview.md
new file mode 100644
index 0000000..543d131
--- /dev/null
+++ b/docs/source/overview.md
@@ -0,0 +1,51 @@
+# Overview
+
+django-viewcomponent is a Django library that provides a way to create reusable components for your Django project. It is inspired by [ViewComponent](https://viewcomponent.org/) for Ruby on Rails.
+
+## What’s a django-viewcomponent
+
+django-viewcomponent is an evolution of the Django partial template, a django-viewcomponent is actually a Python object.
+
+```python
+from django_viewcomponent import component
+
+@component.register("hello")
+class HelloComponent(component.Component):
+ template = "
Hello, {{ self.name }}!
"
+
+ def __init__(self, **kwargs):
+ self.title = kwargs['title']
+```
+
+Notes:
+
+1. Here we defined a Python class `HelloComponent` that inherits from `django_viewcomponent.component.Component`.
+2. `@component.register("hello")` is a decorator that registers the component with the name `hello`.
+3. The `template` attribute is a string that contains the HTML template for the component.
+4. The `__init__` method is the constructor of the component, it receives the `name` parameter and stores it in the `self.name` attribute. So we can access it later in the template via `{{ self.name }}`.
+
+To use the component in Django templates:
+
+```django
+{% load viewcomponent_tags %}
+
+{% component "hello" name='Michael Yin' %}{% endcomponent %}
+```
+
+The `component` tag will initialize the component, and pass the `name` parameter to the `HelloComponent` constructor.
+
+The returning HTML would be:
+
+```html
+
Hello, Michael Yin!
+```
+
+## Why use django-viewcomponent
+
+### Single responsibility
+
+django-viewcomponent can help developers to build reusable components from the Django templates, and make the templates more readable and maintainable.
+
+### Testing
+
+django-viewcomponent components are Python objects, so they can be **easily tested** without touching Django view and Django urls.
diff --git a/docs/source/preview.md b/docs/source/preview.md
new file mode 100644
index 0000000..6596130
--- /dev/null
+++ b/docs/source/preview.md
@@ -0,0 +1,95 @@
+# 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"],
+ "show_previews": DEBUG,
+}
+```
+
+1. `preview_base` is the base path for your previews. You can add multiple paths to it.
+2. `show_previews` is a boolean value, which is used to control whether to show the previews. It is `True` by default, here we set it with same value of `DEBUG`. So the previews will only be shown in the development environment.
+
+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 %}
+
+ {% endcall %}
+
+ {% call component.body %}
+
Small Modal Content
+
This is an example modal dialog box.
+ {% endcall %}
+
+ {% call component.actions %}
+
+
+ {% 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.
diff --git a/docs/source/slot.md b/docs/source/slot.md
new file mode 100644
index 0000000..f25fb4a
--- /dev/null
+++ b/docs/source/slot.md
@@ -0,0 +1,238 @@
+# Slots
+
+If we want to pass more than one content block to the component, `self.content` is not enough, we can use `slots` to achieve this.
+
+Unlike other packages which declare slot via `{% slot %}`, we define the slot in the component class, just like what we defined fields in the Django model
+
+## Basic
+
+Create *components/blog/blog.py*
+
+```python
+from django_viewcomponent import component
+from django_viewcomponent.fields import RendersOneField
+
+
+@component.register("blog")
+class BlogComponent(component.Component):
+ header = RendersOneField(required=True)
+
+ template = """
+ {% load viewcomponent_tags %}
+
+ {{ self.header.value }}
+ """
+```
+
+Notes:
+
+1. `header` is a `RendersOneField` field, it means it can only render one content block, and it is `required`.
+2. `self.header.value` means render the `header` slot field, please note `value` is needed here.
+
+To use the component in Django templates:
+
+```django
+{% component "blog" as blog_component %}
+ {% call blog_component.header %}
+ My Site
+ {% endcall %}
+{% endcomponent %}
+```
+
+Notes:
+
+1. `call blog_component.header` means we call the slot field `header` of the `blog_component`
+2. The returning HTML would be:
+
+```html
+My Site
+```
+
+## Check if the slot is filled
+
+You can use `filled` attribute of the slot field to check if the slot is filled or not.
+
+```django
+{% if self.header.filled %}
+ {{ self.header.value }}
+{% else %}
+ Default title
+{% endif %}
+```
+
+## Render multiple slots
+
+Let's update `BlogComponent`
+
+```python
+from django_viewcomponent import component
+from django_viewcomponent.fields import RendersOneField, RendersManyField
+
+
+@component.register("blog")
+class BlogComponent(component.Component):
+ header = RendersOneField(required=True)
+ posts = RendersManyField(required=True) # new
+
+ template = """
+ {% load viewcomponent_tags %}
+
+ {{ self.header.value }}
+
+ {% for post in self.posts.value %}
+ {{ post }}
+ {% endfor %}
+ """
+```
+
+Notes:
+
+1. `posts` is a `RendersManyField` field, it means it can render multiple content blocks, and it is `required`.
+2. We use `{% for post in self.posts.value %}` to iterate the `posts` slot field.
+
+To use the component in Django templates:
+
+```django
+{% component "blog" as blog_component %}
+ {% call blog_component.header %}
+ My Site
+ {% endcall %}
+
+ {% call blog_component.posts %}
+ Post 1
+ {% endcall %}
+ {% call blog_component.posts %}
+ Post 2
+ {% endcall %}
+
+{% endcomponent %}
+```
+
+Notes:
+
+1. We can call `blog_component.posts` multiple times to pass the block content to the slot field `posts`.
+
+```html
+My Site
+Post 1
+Post 2
+```
+
+## Linking slots with other component
+
+This is a very powerful feature, please read it carefully.
+
+Let's update the `BlogComponent` again
+
+```python
+from django_viewcomponent import component
+from django_viewcomponent.fields import RendersOneField, RendersManyField
+
+
+@component.register("header")
+class HeaderComponent(component.Component):
+ def __init__(self, classes, **kwargs):
+ self.classes = classes
+
+ template = """
+
+ {{ self.content }}
+
+ """
+
+
+@component.register("blog")
+class BlogComponent(component.Component):
+ header = RendersOneField(required=True, component='header')
+ posts = RendersManyField(required=True)
+
+ template = """
+ {% load viewcomponent_tags %}
+
+ {{ self.header.value }}
+
+ {% for post in self.posts.value %}
+ {{ post }}
+ {% endfor %}
+ """
+```
+
+Notes:
+
+1. We added a `HeaderComponent`, which accept a `classes` argument
+2. `header = RendersOneField(required=True, component='header')` means when `{{ self.header.value }}` is rendered, it would use the `HeaderComponent` to render the content.
+
+```django
+{% component "blog" as blog_component %}
+
+ {% call blog_component.header classes='text-lg' %}
+ My Site
+ {% endcall %}
+
+ {% call blog_component.posts %}
+ Post 1
+ {% endcall %}
+ {% call blog_component.posts %}
+ Post 2
+ {% endcall %}
+
+{% endcomponent %}
+```
+
+Notes:
+
+1. When we call `blog_component.header`, the `classes` argument is passed to the `HeaderComponent` automatically.
+
+The final HTML would be
+
+```html
+
+Post 1
+Post 2
+```
+
+Notes:
+
+1. We do not need to store the `classes` to the `BlogComponent` and then pass it to the `HeaderComponent`, just set `component='header'` in the `RendersOneField` field, the `HeaderComponent` would receive the `classes` argument automatically
+2. If you check the template code in the `BlogComponent`, `{{ self.header.value }}` ia very simple to help you understand what it is.
+
+## Component with RendersManyField
+
+If you have
+
+```python
+posts = RendersManyField(required=True, component="post")
+```
+
+In the view template
+
+```django
+{% component 'blog' as component %}
+ {% call component.header classes='text-lg' %}
+ My Site
+ {% endcall %}
+ {% for post in qs %}
+ {% call component.posts post=post %}{% endcall %}
+ {% endfor %}
+{% endcomponent %}
+```
+
+Notes:
+
+1. `qs` is a Django queryset, we iterate the queryset and fill the slot `posts` multiple times, `post` is also passed to the `PostComponent` automatically.
+
+The `PostComponent` can be:
+
+```python
+class PostComponent(component.Component):
+ def __init__(self, post, **kwargs):
+ self.post = post
+
+ template = """
+ {% load viewcomponent_tags %}
+
+
{{ self.post.title }}
+ """
+```
diff --git a/docs/source/templates.md b/docs/source/templates.md
new file mode 100644
index 0000000..f0638b5
--- /dev/null
+++ b/docs/source/templates.md
@@ -0,0 +1,22 @@
+# Templates
+
+## Inline template
+
+You can use `template` variable to define the template inline.
+
+## Template file
+
+To use an external template file, set the `template_name` variable
+
+## Dynamic template
+
+You can use `get_template_names` method to do dynamic template selection.
+
+```python
+class SvgComponent(component.Component):
+ def __init__(self, name, **kwargs):
+ self.name = name
+
+ def get_template_name(self):
+ return f"svg_{self.name}.svg"
+```
diff --git a/docs/source/testing.md b/docs/source/testing.md
new file mode 100644
index 0000000..fcd32ba
--- /dev/null
+++ b/docs/source/testing.md
@@ -0,0 +1,22 @@
+# Testing
+
+```python
+template = Template(
+ """
+ {% load viewcomponent_tags %}
+ {% component 'blog' as component %}
+ {% call component.header classes='text-lg' %}
+ My Site
+ {% endcall %}
+ {% for post in qs %}
+ {% call component.posts post=post %}{% endcall %}
+ {% endfor %}
+ {% endcomponent %}
+ """
+)
+html = template.render(Context({"qs": qs}))
+
+# check the HTML
+```
+
+You can also check the `tests` directory of this project to know more.
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..460e458
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,22 @@
+[tool.poetry]
+name = "django-viewcomponent"
+version = "1.0.5"
+description = "Build reusable components in Django, inspired by Rails ViewComponent"
+authors = ["Michael Yin "]
+license = "MIT"
+homepage = "https://github.com/rails-inspire-django/django-viewcomponent"
+readme = "README.md"
+packages = [{ include = "django_viewcomponent", from = "src" }]
+
+[tool.poetry.urls]
+Changelog = "https://github.com/rails-inspire-django/django-viewcomponent/releases"
+
+[tool.poetry.dependencies]
+python = ">=3.8"
+django = ">=3.0"
+
+[tool.poetry.dev-dependencies]
+
+[build-system]
+requires = ["setuptools", "poetry_core>=1.0"]
+build-backend = "poetry.core.masonry.api"
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..deb53d5
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,11 @@
+pre-commit==2.9.2
+tox==4.11.3
+tox-gh-actions==3.1.3
+
+django==4.2 # for local tests
+typing_extensions
+pytest
+pytest-django
+pytest-xdist
+pytest-mock
+jinja2
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..ccbe96c
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,22 @@
+[flake8]
+ignore = E203, E266, E501, W503, E231, E701, B950, B907
+max-line-length = 88
+max-complexity = 18
+select = B,C,E,F,W,T4,B9
+
+[isort]
+profile = black
+
+[mypy]
+python_version = 3.10
+check_untyped_defs = False
+ignore_missing_imports = True
+warn_unused_ignores = False
+warn_redundant_casts = False
+warn_unused_configs = False
+
+[mypy-*.tests.*]
+ignore_errors = True
+
+[mypy-*.migrations.*]
+ignore_errors = True
diff --git a/src/django_viewcomponent/__init__.py b/src/django_viewcomponent/__init__.py
new file mode 100644
index 0000000..b33edb4
--- /dev/null
+++ b/src/django_viewcomponent/__init__.py
@@ -0,0 +1,37 @@
+import glob
+import importlib
+import importlib.util
+import sys
+from pathlib import Path
+
+from django.template.engine import Engine
+from django_viewcomponent.loaders import ComponentLoader
+
+
+def autodiscover_components():
+ # Autodetect a .py file in a components dir
+ current_engine = Engine.get_default()
+ loader = ComponentLoader(current_engine)
+ dirs = loader.get_dirs()
+ for directory in dirs:
+ for path in glob.iglob(str(directory / "**/*.py"), recursive=True):
+ import_component_file(path)
+
+
+def autodiscover_previews():
+ from django_viewcomponent.app_settings import app_settings
+
+ if app_settings.SHOW_PREVIEWS:
+ 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
+ spec = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH)
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
diff --git a/src/django_viewcomponent/app_settings.py b/src/django_viewcomponent/app_settings.py
new file mode 100644
index 0000000..eb3bcb9
--- /dev/null
+++ b/src/django_viewcomponent/app_settings.py
@@ -0,0 +1,17 @@
+from django.conf import settings
+
+
+class AppSettings:
+ def __init__(self):
+ self.settings = getattr(settings, "VIEW_COMPONENTS", {})
+
+ @property
+ def PREVIEW_BASE(self):
+ return self.settings.setdefault("preview_base", [])
+
+ @property
+ def SHOW_PREVIEWS(self):
+ return self.settings.setdefault("show_previews", True)
+
+
+app_settings = AppSettings()
diff --git a/src/django_viewcomponent/apps.py b/src/django_viewcomponent/apps.py
new file mode 100644
index 0000000..74a80d7
--- /dev/null
+++ b/src/django_viewcomponent/apps.py
@@ -0,0 +1,9 @@
+from django.apps import AppConfig
+
+
+class ComponentsConfig(AppConfig):
+ name = "django_viewcomponent"
+
+ def ready(self):
+ self.module.autodiscover_components()
+ self.module.autodiscover_previews()
diff --git a/src/django_viewcomponent/component.py b/src/django_viewcomponent/component.py
new file mode 100644
index 0000000..ef20ccf
--- /dev/null
+++ b/src/django_viewcomponent/component.py
@@ -0,0 +1,111 @@
+import copy
+import inspect
+from typing import Any, ClassVar, Dict, Optional, Union
+
+from django.core.exceptions import ImproperlyConfigured
+from django.template.base import Template
+from django.template.context import Context
+from django.template.loader import get_template
+
+from django_viewcomponent.component_registry import ( # NOQA
+ AlreadyRegistered,
+ ComponentRegistry,
+ NotRegistered,
+ register,
+ registry,
+)
+from django_viewcomponent.fields import BaseSlotField
+
+
+class Component:
+ template_name: ClassVar[Optional[str]] = None
+ template: ClassVar[Optional[str]] = None
+
+ # if pass HTML to the component without fill tags, it will be stored here
+ # and you can get it using self.content
+ content = ""
+
+ # the name of the component
+ component_name = None
+
+ # the variable name of the component in the context
+ component_target_var = None
+
+ # the context of the component, generated by get_context_data
+ component_context: Dict["str", Any] = {}
+
+ # the context of the outer
+ outer_context: Dict["str", Any] = {}
+
+ def __init__(self, *args, **kwargs):
+ pass
+
+ def __init_subclass__(cls, **kwargs):
+ cls.class_hash = hash(inspect.getfile(cls) + cls.__name__)
+
+ def get_context_data(self, **kwargs) -> Dict[str, Any]:
+ # inspired by rails viewcomponent before_render method
+ # developers can add extra context data by overriding this method
+ self.outer_context["self"] = self
+ if self.component_target_var:
+ self.outer_context[self.component_target_var] = self
+ return self.outer_context
+
+ def get_template_name(self) -> Optional[str]:
+ return self.template_name
+
+ def get_template_string(self) -> Optional[str]:
+ return self.template
+
+ def get_template(self) -> Template:
+ template_string = self.get_template_string()
+ if template_string is not None:
+ return Template(template_string)
+
+ template_name = self.get_template_name()
+ if template_name is not None:
+ return get_template(template_name).template
+
+ raise ImproperlyConfigured(
+ f"Either 'template_name' or 'template' must be set for Component {type(self).__name__}."
+ f"Note: this attribute is not required if you are overriding the class's `get_template*()` methods."
+ )
+
+ def render(
+ self,
+ context_data: Union[Dict[str, Any], Context, None] = None,
+ ) -> str:
+ context_data = context_data or {}
+ if isinstance(context_data, dict):
+ context = Context(context_data)
+ else:
+ context = context_data
+
+ template = self.get_template()
+ return template.render(context)
+
+ def check_slot_fields(self):
+ # check required slot fields
+ for key in self._rendered_fields_dict().keys():
+ field = getattr(self, key)
+ if field.required and not field.filled:
+ raise ValueError(f"Field {key} is required")
+
+ @classmethod
+ def _rendered_fields_dict(cls):
+ """
+ Get slot fields from the Component class fields
+ """
+ rendered_fields_dict = {}
+ for field_name in dir(cls):
+ field = getattr(cls, field_name)
+ if isinstance(field, BaseSlotField):
+ rendered_fields_dict[field_name] = field
+ return rendered_fields_dict
+
+ def create_slot_fields(self):
+ slot_fields = self._rendered_fields_dict()
+ for field_name, field in slot_fields.items():
+ new_field = copy.deepcopy(field)
+ new_field.parent_component = self
+ setattr(self, field_name, new_field)
diff --git a/src/django_viewcomponent/component_registry.py b/src/django_viewcomponent/component_registry.py
new file mode 100644
index 0000000..58fe3b3
--- /dev/null
+++ b/src/django_viewcomponent/component_registry.py
@@ -0,0 +1,57 @@
+class AlreadyRegistered(Exception):
+ pass
+
+
+class NotRegistered(Exception):
+ pass
+
+
+class ComponentRegistry(object):
+ def __init__(self):
+ self._registry = {} # component name -> component_class mapping
+
+ def register(self, name=None, component=None):
+ existing_component = self._registry.get(name)
+ if existing_component and existing_component.class_hash != component.class_hash:
+ raise AlreadyRegistered(
+ 'The component "%s" has already been registered' % name
+ )
+ self._registry[name] = component
+
+ def unregister(self, name):
+ self.get(name)
+
+ del self._registry[name]
+
+ def get(self, name):
+ if name not in self._registry:
+ raise NotRegistered('The component "%s" is not registered' % name)
+
+ return self._registry[name]
+
+ def all(self):
+ return self._registry
+
+ def clear(self):
+ self._registry = {}
+
+
+# This variable represents the global component registry
+registry = ComponentRegistry()
+
+
+def register(name):
+ """Class decorator to register a component.
+
+ Usage:
+
+ @register("my_component")
+ class MyComponent(component.Component):
+ ...
+ """
+
+ def decorator(component):
+ registry.register(name=name, component=component)
+ return component
+
+ return decorator
diff --git a/src/django_viewcomponent/fields.py b/src/django_viewcomponent/fields.py
new file mode 100644
index 0000000..19a975d
--- /dev/null
+++ b/src/django_viewcomponent/fields.py
@@ -0,0 +1,110 @@
+from django.template import Context
+
+from django_viewcomponent.component_registry import registry as component_registry
+
+
+class FieldValue:
+ def __init__(self, dict_data=None, component=None, parent_component=None, **kwargs):
+ self._dict_data = dict_data
+ self._content = self._dict_data.pop("content", "")
+ self._component = component
+ self._parent_component = parent_component
+
+ def __str__(self):
+ if self._component is None:
+ return self._content
+ else:
+ # If the slot field is defined with component, then we will use the component to render
+ return self.render()
+
+ def render(self):
+ component_cls = component_registry.get(self._component)
+
+ component = component_cls(
+ **self._dict_data,
+ )
+ component.component_name = self._component
+
+ # create isolated context for component
+ component.outer_context = Context(
+ self._parent_component.component_context.flatten()
+ )
+
+ # create slot fields
+ component.create_slot_fields()
+
+ component.component_context = component.get_context_data()
+
+ component.content = self._content
+
+ component.check_slot_fields()
+
+ return component.render(component.component_context)
+
+
+class BaseSlotField:
+ parent_component = None
+
+ def __init__(self, value=None, required=False, component=None, **kwargs):
+ self._value = value
+ self._filled = False
+ self._required = required
+ self._component = component
+
+ @classmethod
+ def initialize_fields(cls):
+ cls.header = RendersOneField()
+ cls.main = RendersOneField()
+ cls.footer = RendersOneField()
+
+ @property
+ def value(self):
+ return self._value
+
+ @property
+ def filled(self):
+ return self._filled
+
+ @property
+ def required(self):
+ return self._required
+
+ def handle_call(self, content, **kwargs):
+ raise NotImplementedError("You must implement the `handle_call` method.")
+
+
+class RendersOneField(BaseSlotField):
+ def handle_call(self, content, **kwargs):
+ value_dict = {
+ "content": content,
+ "parent_component": self.parent_component,
+ **kwargs,
+ }
+ value_instance = FieldValue(
+ dict_data=value_dict,
+ component=self._component,
+ parent_component=self.parent_component,
+ )
+
+ self._filled = True
+ self._value = value_instance
+
+
+class RendersManyField(BaseSlotField):
+ def handle_call(self, content, **kwargs):
+ value_dict = {
+ "content": content,
+ "parent_component": self.parent_component,
+ **kwargs,
+ }
+ value_instance = FieldValue(
+ dict_data=value_dict,
+ component=self._component,
+ parent_component=self.parent_component,
+ )
+
+ if self._value is None:
+ self._value = []
+
+ self._value.append(value_instance)
+ self._filled = True
diff --git a/src/django_viewcomponent/loaders.py b/src/django_viewcomponent/loaders.py
new file mode 100644
index 0000000..1b1f7a8
--- /dev/null
+++ b/src/django_viewcomponent/loaders.py
@@ -0,0 +1,35 @@
+"""
+Template loader that loads templates from each Django app's "components" directory.
+"""
+
+from pathlib import Path
+
+from django.conf import settings
+from django.template.loaders.filesystem import Loader as FilesystemLoader
+from django.template.utils import get_app_template_dirs
+
+
+class ComponentLoader(FilesystemLoader):
+ def get_dirs(self):
+ component_dir = "components"
+ directories = set(get_app_template_dirs(component_dir))
+
+ if hasattr(settings, "BASE_DIR"):
+ path = (Path(settings.BASE_DIR) / component_dir).resolve()
+ if path.is_dir():
+ directories.add(path)
+
+ if settings.SETTINGS_MODULE:
+ module_parts = settings.SETTINGS_MODULE.split(".")
+ module_path = Path(*module_parts)
+
+ if len(module_parts) > 2:
+ module_path = Path(*module_parts[:-1])
+
+ # Use list() for < Python 3.9
+ for parent in list(module_path.parents)[:2]:
+ path = (parent / component_dir).resolve()
+ if path.is_dir():
+ directories.add(path)
+
+ return list(directories)
diff --git a/src/django_viewcomponent/preview.py b/src/django_viewcomponent/preview.py
new file mode 100644
index 0000000..b9565ae
--- /dev/null
+++ b/src/django_viewcomponent/preview.py
@@ -0,0 +1,53 @@
+import re
+import inspect
+from django.urls import reverse
+from urllib.parse import urljoin
+from django_viewcomponent.app_settings import app_settings
+
+pattern = re.compile(r'(?
+
+
+
+
+
+
+
+
+{% block content %}
+{% endblock %}
+
+
+
+
+
diff --git a/src/django_viewcomponent/templates/django_viewcomponent/index.html b/src/django_viewcomponent/templates/django_viewcomponent/index.html
new file mode 100644
index 0000000..17c87c5
--- /dev/null
+++ b/src/django_viewcomponent/templates/django_viewcomponent/index.html
@@ -0,0 +1,14 @@
+{% extends 'django_viewcomponent/base.html' %}
+
+{% block content %}
+ {% for preview_name, preview in previews.items %}
+
+ {% if self.header.filled %}
+ {{ self.header.value }}
+ {% else %}
+ Today's date is {{ date }}
+ {% endif %}
+
+
+ {% if self.body.filled %}
+ {{ self.body.value }}
+ {% else %}
+ You have no events today.
+ {% endif %}
+
+
\ No newline at end of file
diff --git a/tests/templates/slotted_component_nesting_template_pt2_dashboard.html b/tests/templates/slotted_component_nesting_template_pt2_dashboard.html
new file mode 100644
index 0000000..253ea2b
--- /dev/null
+++ b/tests/templates/slotted_component_nesting_template_pt2_dashboard.html
@@ -0,0 +1,27 @@
+{% load viewcomponent_tags %}
+
+
+ {% with dashboard_component=self %}
+ {% component "calendar" date="2020-06-06" as calendar_component %}
+
+ {% call calendar_component.header %}
+ {% if dashboard_component.header.filled %}
+ {{ dashboard_component.header.value }}
+ {% else %}
+ Welcome to your dashboard!
+ {% endif %}
+ {% endcall %}
+
+ {% call calendar_component.body %}
+ Here are your to-do items for today:
+ {% endcall %}
+
+ {% endcomponent %}
+ {% endwith %}
+
+
+ {% for item in items %}
+