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 +

+ My Site +

+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 %} +

+ {{ preview_name }} +

+ + {% endfor %} +{% endblock %} \ No newline at end of file diff --git a/src/django_viewcomponent/templates/django_viewcomponent/preview.html b/src/django_viewcomponent/templates/django_viewcomponent/preview.html new file mode 100644 index 0000000..d3bca4a --- /dev/null +++ b/src/django_viewcomponent/templates/django_viewcomponent/preview.html @@ -0,0 +1,14 @@ +{% extends 'django_viewcomponent/base.html' %} + +{% block content %} + + {{ preview_html }} + + {% if preview_source %} +
+

Source:

+
{{ preview_source }}
+
+ {% endif %} + +{% endblock %} diff --git a/src/django_viewcomponent/templates/django_viewcomponent/previews.html b/src/django_viewcomponent/templates/django_viewcomponent/previews.html new file mode 100644 index 0000000..5940a8a --- /dev/null +++ b/src/django_viewcomponent/templates/django_viewcomponent/previews.html @@ -0,0 +1,14 @@ +{% extends 'django_viewcomponent/base.html' %} + +{% block content %} + +

+ {{ preview.preview_name }} +

+ + +{% endblock %} \ No newline at end of file diff --git a/src/django_viewcomponent/templatetags/__init__.py b/src/django_viewcomponent/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/django_viewcomponent/templatetags/viewcomponent_tags.py b/src/django_viewcomponent/templatetags/viewcomponent_tags.py new file mode 100644 index 0000000..4f6757c --- /dev/null +++ b/src/django_viewcomponent/templatetags/viewcomponent_tags.py @@ -0,0 +1,242 @@ +from typing import Type + +import django.template +from django.template import Context +from django.template.base import FilterExpression, Node, NodeList +from django.template.exceptions import TemplateSyntaxError +from django.template.library import parse_bits + +from django_viewcomponent.component import Component +from django_viewcomponent.component_registry import registry as component_registry +from django_viewcomponent.fields import BaseSlotField + +register = django.template.Library() + + +@register.tag("call") +def do_call(parser, token): + bits = token.split_contents() + tag_name = "call" + tag_args, tag_kwargs = parse_bits( + parser=parser, + bits=bits, + params=[], + takes_context=False, + name=tag_name, + varargs=True, + varkw=[], + defaults=None, + kwonly=[], + kwonly_defaults=None, + ) + + if len(tag_args) > 1: + # At least one position arg, so take the first as the component name + args = tag_args[1:] + kwargs = tag_kwargs + else: + raise TemplateSyntaxError(f"Syntax error in '{tag_name}' tag") + + nodelist = parser.parse(parse_until=["endcall"]) + parser.delete_first_token() + + return CallNode( + parser=parser, + nodelist=nodelist, + args=args, + kwargs=kwargs, + ) + + +class CallNode(Node): + def __init__( + self, + parser, + nodelist: NodeList, + args, + kwargs, + ): + self.parser = parser + self.nodelist: NodeList = nodelist + self.args = args + self.kwargs = kwargs + + def __repr__(self): + raise NotImplementedError + + def render(self, context): + content = self.nodelist.render(context) + + resolved_kwargs = { + key: safe_resolve(kwarg, context) for key, kwarg in self.kwargs.items() + } + + if "content" in resolved_kwargs: + raise ValueError( + "The 'content' kwarg is reserved and cannot be passed in component call tag" + ) + + resolved_kwargs["content"] = content + + component_token, field_token = self.args[0].token.split(".") + component_instance = FilterExpression(component_token, self.parser).resolve( + context + ) + if not component_instance: + raise ValueError(f"Component {component_token} not found in context") + + field = getattr(component_instance, field_token, None) + if not field: + raise ValueError( + f"Field {field_token} not found in component {component_token}" + ) + + if isinstance(field, BaseSlotField): + # call field + return field.handle_call(**resolved_kwargs) or "" + + +class ComponentNode(Node): + def __init__( + self, + name_fexp: FilterExpression, + context_args, + context_kwargs, + nodelist: NodeList, + target_var=None, + ): + self.name_fexp = name_fexp + self.context_args = context_args or [] + self.context_kwargs = context_kwargs or {} + self.nodelist = nodelist + self.target_var = target_var + + def __repr__(self): + return "" % ( + self.name_fexp, + getattr( + self, "nodelist", None + ), # 'nodelist' attribute only assigned later. + ) + + def render(self, context: Context): + resolved_component_name = self.name_fexp.resolve(context) + + # get component class + component_cls: Type[Component] = component_registry.get(resolved_component_name) + + # Resolve FilterExpressions and Variables that were passed as args to the + # component, then call component's context method + # to get values to insert into the context + resolved_component_args = [ + safe_resolve(arg, context) for arg in self.context_args + ] + resolved_component_kwargs = { + key: safe_resolve(kwarg, context) + for key, kwarg in self.context_kwargs.items() + } + + # create component + component: Component = component_cls( + *resolved_component_args, + **resolved_component_kwargs, + ) + component.component_name = resolved_component_name + component.component_target_var = self.target_var + + # create isolated context for component + component.outer_context = Context(context.flatten()) + + # create slot fields + component.create_slot_fields() + + component.component_context = component.get_context_data() + + # render children nodelist + component.content = self.nodelist.render(component.component_context) + + component.check_slot_fields() + + # render component + return component.render(component.component_context) + + +@register.tag(name="component") +def do_component(parser, token): + """ + To give the component access to the template context: + {% component "name" positional_arg keyword_arg=value ... %} + + To render the component in an isolated context: + {% component "name" positional_arg keyword_arg=value ... only %} + + Positional and keyword arguments can be literals or template variables. + The component name must be a single- or double-quotes string and must + be either the first positional argument or, if there are no positional + arguments, passed as 'name'. + """ + + bits = token.split_contents() + + # check as keyword + target_var = None + if len(bits) >= 4 and bits[-2] == "as": + target_var = bits[-1] + bits = bits[:-2] + + component_name, context_args, context_kwargs = parse_component_with_arguments( + parser, bits, "component" + ) + nodelist: NodeList = parser.parse(parse_until=["endcomponent"]) + parser.delete_first_token() + + component_node = ComponentNode( + name_fexp=FilterExpression(component_name, parser), + context_args=context_args, + context_kwargs=context_kwargs, + nodelist=nodelist, + target_var=target_var, + ) + + return component_node + + +def parse_component_with_arguments(parser, bits, tag_name): + tag_args, tag_kwargs = parse_bits( + parser=parser, + bits=bits, + params=["tag_name", "name"], + takes_context=False, + name=tag_name, + varargs=True, + varkw=[], + defaults=None, + kwonly=[], + kwonly_defaults=None, + ) + + if tag_name != tag_args[0].token: + raise RuntimeError( + f"Internal error: Expected tag_name to be {tag_name}, but it was {tag_args[0].token}" + ) + if len(tag_args) > 1: + # At least one position arg, so take the first as the component name + component_name = tag_args[1].token + context_args = tag_args[2:] + context_kwargs = tag_kwargs + else: + raise TemplateSyntaxError( + f"Call the '{tag_name}' tag with a component name as the first parameter" + ) + + return component_name, context_args, context_kwargs + + +def safe_resolve(context_item, context): + """Resolve FilterExpressions and Variables in context if possible. Return other items unchanged.""" + + return ( + context_item.resolve(context) + if hasattr(context_item, "resolve") + else context_item + ) diff --git a/src/django_viewcomponent/urls.py b/src/django_viewcomponent/urls.py new file mode 100644 index 0000000..01c4aff --- /dev/null +++ b/src/django_viewcomponent/urls.py @@ -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('/', previews_view, name='previews'), + path('//', preview_view, name='preview'), +] diff --git a/src/django_viewcomponent/views.py b/src/django_viewcomponent/views.py new file mode 100644 index 0000000..c03c9f7 --- /dev/null +++ b/src/django_viewcomponent/views.py @@ -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) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..499d3b5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,66 @@ +import pathlib + +import pytest + +from django_viewcomponent import component + + +def pytest_configure(): + from django.conf import settings + + settings.configure( + SECRET_KEY="seekret", + DATABASES={ + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "mem_db"}, + }, + TEMPLATES=[ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [pathlib.Path(__file__).parent.absolute() / "templates"], + "APP_DIRS": False, + "OPTIONS": { + "loaders": [ + ( + "django.template.loaders.cached.Loader", + [ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + "django_viewcomponent.loaders.ComponentLoader", + ], + ) + ], + "builtins": [ + "django_viewcomponent.templatetags.viewcomponent_tags", + ], + }, + } + ], + INSTALLED_APPS=[ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django_viewcomponent", + "tests.testapp", + ], + ROOT_URLCONF="tests.testapp.urls", + VIEW_COMPONENTS={ + "preview_base": ["previews"], + }, + ) + + +@pytest.fixture(autouse=True) +def cleanup_after_each_test(): + yield + + # NOTE: component.registry is global, so need to clear after each test + component.registry.clear() + + +@pytest.fixture +def post(): + from tests.testapp.models import Post + + return Post.objects.create(title="test", description="test") diff --git a/tests/previews/simple_preview.py b/tests/previews/simple_preview.py new file mode 100644 index 0000000..ee1038f --- /dev/null +++ b/tests/previews/simple_preview.py @@ -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 = """ + + {{ self.content }} + + """ + + 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})) diff --git a/tests/templates/conditional_template.html b/tests/templates/conditional_template.html new file mode 100644 index 0000000..3f7c7de --- /dev/null +++ b/tests/templates/conditional_template.html @@ -0,0 +1,18 @@ +{% load viewcomponent_tags %} +{% if self.branch == 'a' %} +

+ {% if self.slot_a.filled %} + {{ self.slot_a.value }} + {% else %} + Default A + {% endif %} +

+{% elif self.branch == 'b' %} +

+ {% if self.slot_b.filled %} + {{ self.slot_b.value }} + {% else %} + Default B + {% endif %} +

+{% endif %} diff --git a/tests/templates/extendable_template_with_blocks.html b/tests/templates/extendable_template_with_blocks.html new file mode 100644 index 0000000..d7f69d8 --- /dev/null +++ b/tests/templates/extendable_template_with_blocks.html @@ -0,0 +1,12 @@ +{% load viewcomponent_tags %} + + + +
+
+ {% block body %} + {% endblock %} +
+
+ + \ No newline at end of file diff --git a/tests/templates/if_variable_template.html b/tests/templates/if_variable_template.html new file mode 100644 index 0000000..a2dff05 --- /dev/null +++ b/tests/templates/if_variable_template.html @@ -0,0 +1,4 @@ +Variable: {{ self.kwargs.variable }} +{% if self.kwargs.variable2 %} + Variable2: {{ self.kwargs.variable2 }} +{% endif %} diff --git a/tests/templates/nested_slot_template.html b/tests/templates/nested_slot_template.html new file mode 100644 index 0000000..7001273 --- /dev/null +++ b/tests/templates/nested_slot_template.html @@ -0,0 +1,11 @@ +{% if self.outer.filled %} + {{ self.outer.value }} +{% else %} +
+ {% if self.inner.filled %} + {{ self.inner.value }} + {% else %} + Default + {% endif %} +
+{% endif %} diff --git a/tests/templates/simple_template.html b/tests/templates/simple_template.html new file mode 100644 index 0000000..6b4564d --- /dev/null +++ b/tests/templates/simple_template.html @@ -0,0 +1 @@ +Variable: {{ self.kwargs.variable }} diff --git a/tests/templates/slotted_component_nesting_template_pt1_calendar.html b/tests/templates/slotted_component_nesting_template_pt1_calendar.html new file mode 100644 index 0000000..09826ea --- /dev/null +++ b/tests/templates/slotted_component_nesting_template_pt1_calendar.html @@ -0,0 +1,16 @@ +
+

+ {% 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 %} +
  1. {{ item }}
  2. + {% endfor %} +
+
diff --git a/tests/templates/slotted_template.html b/tests/templates/slotted_template.html new file mode 100644 index 0000000..b811729 --- /dev/null +++ b/tests/templates/slotted_template.html @@ -0,0 +1,26 @@ + +
+ {% if self.header.filled %} + {{ self.header.value }} + {% else %} + Default header + {% endif %} +
+ +
+ {% if self.main.filled %} + {{ self.main.value }} + {% else %} + Default main + {% endif %} +
+ +
+ {% if self.footer.filled %} + {{ self.footer.value }} + {% else %} + Default footer + {% endif %} +
+ +
diff --git a/tests/templates/slotted_template_with_missing_variable.html b/tests/templates/slotted_template_with_missing_variable.html new file mode 100644 index 0000000..f8b0a1c --- /dev/null +++ b/tests/templates/slotted_template_with_missing_variable.html @@ -0,0 +1,29 @@ + + + {{ missing_context_variable }} + +
+ {% if self.header.filled %} + {{ self.header.value }} + {% else %} + Default header + {% endif %} +
+ +
+ {% if self.main.filled %} + {{ self.main.value }} + {% else %} + Default main + {% endif %} +
+ +
+ {% if self.footer.filled %} + {{ self.footer.value }} + {% else %} + Default footer + {% endif %} +
+ +
diff --git a/tests/templates/svg_moon.svg b/tests/templates/svg_moon.svg new file mode 100644 index 0000000..dad5fa8 --- /dev/null +++ b/tests/templates/svg_moon.svg @@ -0,0 +1 @@ +{{ self.name }} diff --git a/tests/templates/svg_sun.svg b/tests/templates/svg_sun.svg new file mode 100644 index 0000000..dad5fa8 --- /dev/null +++ b/tests/templates/svg_sun.svg @@ -0,0 +1 @@ +{{ self.name }} diff --git a/tests/templates/template_with_conditional_slots.html b/tests/templates/template_with_conditional_slots.html new file mode 100644 index 0000000..f9cd596 --- /dev/null +++ b/tests/templates/template_with_conditional_slots.html @@ -0,0 +1,15 @@ +{# Example from django-components/issues/98 #} +
+
+ {% if self.title.filled %} + {{ self.title.value }} + {% else %} + Title + {% endif %} +
+ + {% if self.subtitle.filled %} +
{{ self.subtitle.value }}
+ {% endif %} + +
diff --git a/tests/templates/template_with_if_elif_else_conditional_slots.html b/tests/templates/template_with_if_elif_else_conditional_slots.html new file mode 100644 index 0000000..b33592d --- /dev/null +++ b/tests/templates/template_with_if_elif_else_conditional_slots.html @@ -0,0 +1,24 @@ +{# Example from django-components/issues/98 #} +{% load viewcomponent_tags %} +
+
+ {% if self.title.filled %} + {{ self.title.value }} + {% else %} + Title + {% endif %} +
+ + {% if self.subtitle.filled %} +
{{ self.subtitle.value }}
+ {% endif %} + + {% if self.alt_subtitle.filled %} +
{{ self.alt_subtitle.value }}
+ {% endif %} + + {% if not self.subtitle.filled and not self.alt_subtitle.filled %} +
Nothing filled!
+ {% endif %} + +
diff --git a/tests/templates/template_with_illegal_slot.html b/tests/templates/template_with_illegal_slot.html new file mode 100644 index 0000000..14a5290 --- /dev/null +++ b/tests/templates/template_with_illegal_slot.html @@ -0,0 +1,2 @@ +{% load viewcomponent_tags %} +{% include 'slotted_template.html' with context=None only %} diff --git a/tests/templates/template_with_nonunique_slots.html b/tests/templates/template_with_nonunique_slots.html new file mode 100644 index 0000000..92f5452 --- /dev/null +++ b/tests/templates/template_with_nonunique_slots.html @@ -0,0 +1,4 @@ +{% load viewcomponent_tags %} +
{% slot "header" %}Default header{% endslot %}
+
{% slot "header" %}Default main header{% endslot %}
{# <- whoops! slot name 'header' used twice. #} +
{% slot "footer" %}Default footer{% endslot %}
diff --git a/tests/test_component.py b/tests/test_component.py new file mode 100644 index 0000000..121c719 --- /dev/null +++ b/tests/test_component.py @@ -0,0 +1,92 @@ +import pytest +from django.core.exceptions import ImproperlyConfigured +from django.template import Context + +from django_viewcomponent import component +from tests.utils import assert_dom_equal + + +class TestComponent: + def test_component_require_template(self): + """ + Component should raise ImproperlyConfigured if no template is provided. + """ + + class EmptyComponent(component.Component): + pass + + with pytest.raises(ImproperlyConfigured): + EmptyComponent().get_template() + + def test_component_args(self): + """ + Pass arguments to component constructor, set it as instance attribute + and get the value as {{ self.attribute }} in the template. + """ + + class SimpleComponent(component.Component): + template = "
{{ self.size }}
" + + def __init__(self, size): + self.size = size + + comp = SimpleComponent(size="sm") + assert_dom_equal("
sm
", comp.render(comp.get_context_data())) + + def test_component_with_dynamic_template(self): + """ + Overwrite get_template_name to return a dynamic template name. + """ + + class SvgComponent(component.Component): + def __init__(self, name, css_class="", title="", **kwargs): + self.name = name + self.css_class = css_class + self.title = title + + def get_template_name(self): + return f"svg_{self.name}.svg" + + comp = SvgComponent(name="sun") + assert_dom_equal("sun", comp.render(comp.get_context_data())) + + comp = SvgComponent(name="moon") + assert_dom_equal("moon", comp.render(comp.get_context_data())) + + +class TestComponentContext: + def test_component_outer_context(self): + """ + Access outer context in the component template. + """ + + class SimpleComponent(component.Component): + template = "
{{ variable }}
" + + comp = SimpleComponent() + assert_dom_equal("
test
", comp.render({"variable": "test"})) + + def test_component_with_extra_context_variables(self): + class FilteredComponent(component.Component): + template = """ +
{{ self.var1 }}
+
{{ var2 }}
+
{{ outer_variable }}
+ """ + + def __init__(self, var1): + self.var1 = var1 + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["var2"] = self.var1 + return context + + # ComponentNode do the same way + comp = FilteredComponent(var1="test") + comp.outer_context = Context({"outer_variable": "test"}) + + assert_dom_equal( + "
test
test
test
", + comp.render(comp.get_context_data()), + ) diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 0000000..cab5797 --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,76 @@ +import pytest +from django.template import Context, Template + +from django_viewcomponent import component +from django_viewcomponent.fields import RendersOneField +from tests.utils import assert_dom_equal + + +class ChildComponent(component.Component): + title = RendersOneField() + + template = """ +
{{ variable }}
+
{{ self.title.value }}
+ """ + + +class ParentComponent(component.Component): + template = """ + {% load viewcomponent_tags %} + {% component "child" as component %} + {% call component.title %} + {{ variable }} + {% endcall %} + {% endcomponent %} + """ + + def __init__(self, variable=None, **kwargs): + self.variable = variable + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + if self.variable: + # add extra context data so child component can access it + context["variable"] = self.variable + return context + + +class TestContextBehavior: + @pytest.fixture(autouse=True) + def register_component(self): + component.registry.register("parent", ParentComponent) + component.registry.register("child", ChildComponent) + + def test_context(self): + template = """ + {% load viewcomponent_tags %} + {% with variable="test123" %} + {% component "parent" %} + {% endcomponent %} + {% endwith %} + """ + rendered = Template(template).render(Context()) + expected = """ +
test123
+
test123
+ """ + assert_dom_equal(rendered, expected) + + def test_context_overwrite(self): + """ + Parent component overwrite the context variable in the get_context_data method + """ + template = """ + {% load viewcomponent_tags %} + {% with variable="test123" %} + {% component "parent" variable="test456" %} + {% endcomponent %} + {% endwith %} + """ + rendered = Template(template).render(Context()) + expected = """ +
test456
+
test456
+ """ + assert_dom_equal(rendered, expected) diff --git a/tests/test_preview.py b/tests/test_preview.py new file mode 100644 index 0000000..79ab07a --- /dev/null +++ b/tests/test_preview.py @@ -0,0 +1,77 @@ +import pytest +from django_viewcomponent.preview import ViewComponentPreview +from django.urls import reverse +from django_viewcomponent import component + + +@pytest.fixture(autouse=True) +def register_component(): + from tests.previews.simple_preview import ExampleComponent + component.registry.register("example", ExampleComponent) + + +class TestBasicPreview: + def test_setup(self): + """ + In tests/conftest.py + + VIEW_COMPONENTS={ + "preview_base": ["previews"], + }, + """ + assert len(ViewComponentPreview.previews.keys()) + + def test_preview_index_view(self, client): + response = client.get(reverse('django_viewcomponent:preview-index')) + + assert response.status_code == 200 + assert b"simple_example_component" in response.content + assert b"with_title" in response.content + + def test_previews(self, client): + response = client.get(reverse('django_viewcomponent:previews', kwargs={'preview_name': 'simple_example_component'})) + + assert response.status_code == 200 + assert b"with_title" in response.content + + def test_preview(self, client): + response = client.get(reverse('django_viewcomponent:preview', kwargs={ + 'preview_name': 'simple_example_component', + 'example_name': 'with_title', + })) + + assert response.status_code == 200 + assert b"default title" in response.content + + def test_simple_preview_with_querystring(self, client): + query_params = {'title': 'hello world'} + response = client.get(reverse('django_viewcomponent:preview', kwargs={ + 'preview_name': 'simple_example_component', + 'example_name': 'with_title', + }), data=query_params) + + assert response.status_code == 200 + assert b"hello world" in response.content + + +class TestComponentPreview: + + def test_component_call(self, client): + query_params = {'title': 'hello world'} + response = client.get(reverse('django_viewcomponent:preview', kwargs={ + 'preview_name': 'simple_example_component', + 'example_name': 'with_component_call', + }), data=query_params) + + assert response.status_code == 200 + assert b'' in response.content + + def test_template_render(self, client): + query_params = {'title': 'hello world'} + response = client.get(reverse('django_viewcomponent:preview', kwargs={ + 'preview_name': 'simple_example_component', + 'example_name': 'with_template_render', + }), data=query_params) + + assert response.status_code == 200 + assert b'' in response.content diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..85c1c2e --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,68 @@ +import pytest + +from django_viewcomponent import component + + +@pytest.fixture +def registry(): + return component.ComponentRegistry() + + +class MockComponent(component.Component): + pass + + +class MockComponent2(component.Component): + pass + + +class MockComponentView(component.Component): + def get(self, request, *args, **kwargs): + pass + + +def test_register_class_decorator(registry): + @component.register("decorated_component") + class TestComponent(component.Component): + pass + + assert component.registry.get("decorated_component") == TestComponent + + +def test_simple_register(registry): + registry.register(name="testcomponent", component=MockComponent) + assert registry.all() == {"testcomponent": MockComponent} + + +def test_register_two_components(registry): + registry.register(name="testcomponent", component=MockComponent) + registry.register(name="testcomponent2", component=MockComponent) + assert registry.all() == { + "testcomponent": MockComponent, + "testcomponent2": MockComponent, + } + + +def test_prevent_registering_different_components_with_the_same_name(registry): + registry.register(name="testcomponent", component=MockComponent) + with pytest.raises(component.AlreadyRegistered): + registry.register(name="testcomponent", component=MockComponent2) + + +def test_allow_duplicated_registration_of_the_same_component(registry): + try: + registry.register(name="testcomponent", component=MockComponentView) + registry.register(name="testcomponent", component=MockComponentView) + except component.AlreadyRegistered: + pytest.fail("Should not raise AlreadyRegistered") + + +def test_simple_unregister(registry): + registry.register(name="testcomponent", component=MockComponent) + registry.unregister(name="testcomponent") + assert registry.all() == {} + + +def test_raises_on_failed_unregister(registry): + with pytest.raises(component.NotRegistered): + registry.unregister(name="testcomponent") diff --git a/tests/test_tags.py b/tests/test_tags.py new file mode 100644 index 0000000..421a096 --- /dev/null +++ b/tests/test_tags.py @@ -0,0 +1,1019 @@ +import pytest +from django.template import Context, Template, TemplateSyntaxError + +import django_viewcomponent +import django_viewcomponent.component_registry +from django_viewcomponent import component +from django_viewcomponent.fields import RendersManyField, RendersOneField +from tests.testapp.models import Post +from tests.utils import assert_dom_equal + + +class SimpleComponent(component.Component): + template_name = "simple_template.html" + + def __init__(self, **kwargs): + self.kwargs = kwargs + + +class IfVariableComponent(SimpleComponent): + template_name = "if_variable_template.html" + + def __init__(self, **kwargs): + self.kwargs = kwargs + + +class SlottedComponent(component.Component): + header = RendersOneField() + main = RendersOneField() + footer = RendersOneField() + + template_name = "slotted_template.html" + + +class BrokenComponent(component.Component): + template_name = "template_with_illegal_slot.html" + + +class NonUniqueSlotsComponent(component.Component): + template_name = "template_with_nonunique_slots.html" + + +class SlottedComponentWithMissingVariable(component.Component): + header = RendersOneField() + main = RendersOneField() + footer = RendersOneField() + + template_name = "slotted_template_with_missing_variable.html" + + +class SlottedComponentNoSlots(component.Component): + template = "" + + +class SlottedComponentWithContext(component.Component): + header = RendersOneField() + main = RendersOneField() + footer = RendersOneField() + + template_name = "slotted_template.html" + + def __init__(self, **kwargs): + self.variable = kwargs.pop("variable") + + def get_context_data(self, **kwargs): + context = super().get_context_data() + context["variable"] = self.variable + return context + + +class CalendarComponent(component.Component): + """Nested in ComponentWithNestedComponent""" + + header = RendersOneField() + body = RendersOneField() + + template_name = "slotted_component_nesting_template_pt1_calendar.html" + + +class DashboardComponent(component.Component): + header = RendersOneField() + + template_name = "slotted_component_nesting_template_pt2_dashboard.html" + + +class TestComponentTemplateTag: + def test_single_component(self): + component.registry.register(name="test", component=SimpleComponent) + + simple_tag_template = """ + {% load viewcomponent_tags %} + {% component "test" variable="variable" %}{% endcomponent %} + """ + + template = Template(simple_tag_template) + assert_dom_equal( + "Variable: variable", template.render(Context()) + ) + + def test_call_with_invalid_name(self): + """ + Calling unregistered component should raise an error + """ + simple_tag_template = """ + {% load viewcomponent_tags %} + {% component "test" variable="variable" %}{% endcomponent %} + """ + + template = Template(simple_tag_template) + with pytest.raises(django_viewcomponent.component_registry.NotRegistered): + template.render(Context({})) + + def test_component_content(self): + """ + if pass HTML to the component without fill tags, it will be available in self.content + """ + + class Component(component.Component): + template = "
{{ self.content }}
" + + component.registry.register("test", Component) + simple_tag_template = """ + {% load viewcomponent_tags %} + {% component 'test' %} + Hello World + {% endcomponent %} + """ + template = Template(simple_tag_template) + rendered = template.render(Context({})) + assert_dom_equal(rendered, "
Hello World
") + + def test_component_called_with_positional_name(self): + component.registry.register(name="test", component=SimpleComponent) + + simple_tag_template = """ + {% load viewcomponent_tags %} + {% component "test" variable="variable" %}{% endcomponent %} + """ + template = Template(simple_tag_template) + rendered = template.render(Context({})) + assert_dom_equal(rendered, "Variable: variable") + + def test_component_called_with_singlequoted_name(self): + component.registry.register(name="test", component=SimpleComponent) + + simple_tag_template = """ + {% load viewcomponent_tags %} + {% component 'test' variable="variable" %}{% endcomponent %} + """ + + template = Template(simple_tag_template) + rendered = template.render(Context({})) + assert_dom_equal(rendered, "Variable: variable\n") + + def test_component_called_with_variable_as_name(self): + component.registry.register(name="test", component=SimpleComponent) + + simple_tag_template = """ + {% load viewcomponent_tags %} + {% with component_name="test" %} + {% component component_name variable="variable" %}{% endcomponent %} + {% endwith %} + """ + template = Template(simple_tag_template) + rendered = template.render(Context({})) + assert_dom_equal(rendered, "Variable: variable\n") + + def test_component_called_with_invalid_variable_as_name(self): + component.registry.register(name="test", component=SimpleComponent) + + simple_tag_template = """ + {% load viewcomponent_tags %} + {% with component_name="BLAHONGA" %} + {% component component_name variable="variable" %}{% endcomponent %} + {% endwith %} + """ + template = Template(simple_tag_template) + + with pytest.raises(django_viewcomponent.component_registry.NotRegistered): + template.render(Context({})) + + def test_call_component_with_two_variables(self): + component.registry.register(name="test", component=IfVariableComponent) + + simple_tag_template = """ + {% load viewcomponent_tags %} + {% component "test" variable="variable" variable2="hej" %}{% endcomponent %} + """ + template = Template(simple_tag_template) + + rendered = template.render(Context({})) + expected_outcome = ( + """Variable: variable\n""" + """Variable2: hej""" + ) + assert_dom_equal(expected_outcome, rendered) + + +class TestComponentSlotsTemplateTag: + def test_slotted_template_basic(self): + component.registry.register(name="test1", component=SlottedComponent) + component.registry.register(name="test2", component=SimpleComponent) + + template = Template( + """ + {% load viewcomponent_tags %} + {% component "test1" as component %} + {% call component.header %} + Custom header + {% endcall %} + {% call component.main %} + {% component "test2" variable="variable" %}{% endcomponent %} + {% endcall %} + {% endcomponent %} + """ + ) + rendered = template.render(Context({})) + + assert_dom_equal( + rendered, + """ + +
Custom header
+
Variable: variable
+
Default footer
+
+ """, + ) + + def test_slotted_template_with_context_var(self): + component.registry.register(name="test1", component=SlottedComponentWithContext) + + template = Template( + """ + {% load viewcomponent_tags %} + {% with my_first_variable="test123" %} + {% component "test1" variable="test456" as component %} + {% call component.main %} + {{ my_first_variable }} - {{ variable }} + {% endcall %} + {% call component.footer %} + {{ my_second_variable }} + {% endcall %} + {% endcomponent %} + {% endwith %} + """ + ) + rendered = template.render(Context({"my_second_variable": "test321"})) + + assert_dom_equal( + rendered, + """ + +
Default header
+
test123 - test456
+
test321
+
+ """, + ) + + def test_slotted_template_that_uses_missing_variable(self): + component.registry.register( + name="test", component=SlottedComponentWithMissingVariable + ) + template = Template( + """ + {% load viewcomponent_tags %} + {% component 'test' %}{% endcomponent %} + """ + ) + rendered = template.render(Context({})) + + assert_dom_equal( + """ + +
Default header
+
Default main
+
Default footer
+
+ """, + rendered, + ) + + def test_slotted_template_no_slots_filled(self): + component.registry.register(name="test", component=SlottedComponent) + + template = Template( + '{% load viewcomponent_tags %}{% component "test" %}{% endcomponent %}' + ) + rendered = template.render(Context({})) + + assert_dom_equal( + """ + +
Default header
+
Default main
+
Default footer
+
+ """, + rendered, + ) + + def test_slotted_template_without_slots(self): + component.registry.register(name="test", component=SlottedComponentNoSlots) + template = Template( + """ + {% load viewcomponent_tags %} + {% component "test" %}{% endcomponent %} + """ + ) + rendered = template.render(Context({})) + + assert_dom_equal("", rendered) + + def test_slotted_template_without_slots_and_single_quotes(self): + component.registry.register(name="test", component=SlottedComponentNoSlots) + template = Template( + """ + {% load viewcomponent_tags %} + {% component 'test' %}{% endcomponent %} + """ + ) + rendered = template.render(Context({})) + + assert_dom_equal("", rendered) + + def test_missing_required_slot_raises_error(self): + class Component(component.Component): + title = RendersOneField(required=True) + + template = "
{{ self.title.value }}
" + + component.registry.register("test", Component) + template = Template( + """ + {% load viewcomponent_tags %} + {% component 'test' %} + {% endcomponent %} + """ + ) + with pytest.raises(ValueError): + template.render(Context({})) + + def test_fill_tag_can_occur_within_component_nested_with_content( + self, + ): + class Component(component.Component): + template = "
{{ self.content }}
" + + component.registry.register("test", Component) + component.registry.register("slotted", SlottedComponent) + + template = Template( + """ + {% load viewcomponent_tags %} + {% component 'test' as component_1 %} + {% component "slotted" as component_2 %} + {% call component_2.header %}This Is Allowed{% endcall %} + {% call component_2.main %}{% endcall %} + {% call component_2.footer %}{% endcall %} + {% endcomponent %} + {% endcomponent %} + """ + ) + expected = """ +
+ +
This Is Allowed
+
+
+
+
+ """ + rendered = template.render(Context({})) + assert_dom_equal(expected, rendered) + + +class TestMultiComponentSlots: + def register_components(self): + component.registry.register("first_component", SlottedComponent) + component.registry.register("second_component", SlottedComponentWithContext) + + def make_template(self, first_component_slot="", second_component_slot=""): + return Template( + "{% load viewcomponent_tags %}" + "{% component 'first_component' as component_1 %}" + + first_component_slot + + "{% endcomponent %}" + "{% component 'second_component' variable='xyz' as component_2 %}" + + second_component_slot + + "{% endcomponent %}" + ) + + def expected_result(self, first_component_slot="", second_component_slot=""): + return ( + "
{}
".format( + first_component_slot or "Default header" + ) + + "
Default main
Default footer
" + + "
{}
".format( + second_component_slot or "Default header" + ) + + "
Default main
Default footer
" + ) + + def wrap_with_slot_tags(self, field, s): + return f"{{% call {field} %}}" + s + "{% endcall %}" + + def test_both_components_render_correctly_with_no_slots(self): + self.register_components() + rendered = self.make_template().render(Context({})) + assert_dom_equal(self.expected_result(), rendered) + + def test_both_components_render_correctly_with_slots(self): + self.register_components() + first_slot_content = "

Slot #1

" + second_slot_content = "
Slot #2
" + first_slot = self.wrap_with_slot_tags("component_1.header", first_slot_content) + second_slot = self.wrap_with_slot_tags( + "component_2.header", second_slot_content + ) + rendered = self.make_template(first_slot, second_slot).render(Context({})) + assert_dom_equal( + self.expected_result(first_slot_content, second_slot_content), rendered + ) + + def test_both_components_render_correctly_when_only_first_has_slots(self): + self.register_components() + first_slot_content = "

Slot #1

" + first_slot = self.wrap_with_slot_tags("component_1.header", first_slot_content) + rendered = self.make_template(first_slot).render(Context({})) + assert_dom_equal(self.expected_result(first_slot_content), rendered) + + def test_both_components_render_correctly_when_only_second_has_slots(self): + self.register_components() + second_slot_content = "
Slot #2
" + second_slot = self.wrap_with_slot_tags( + "component_2.header", second_slot_content + ) + rendered = self.make_template("", second_slot).render(Context({})) + assert_dom_equal(self.expected_result("", second_slot_content), rendered) + + +class TestNestedSlot: + class NestedComponent(component.Component): + outer = RendersOneField() + inner = RendersOneField() + + template_name = "nested_slot_template.html" + + def test_default_slot_contents_render_correctly(self): + component.registry.register("test", self.NestedComponent) + + template = Template( + """ + {% load viewcomponent_tags %} + {% component 'test' %}{% endcomponent %} + """ + ) + rendered = template.render(Context({})) + assert_dom_equal(rendered, '
Default
') + + def test_inner_slot_overriden(self): + component.registry.register("test", self.NestedComponent) + + template = Template( + """ + {% load viewcomponent_tags %} + {% component 'test' as component %}{% call component.inner %}Override{% endcall %}{% endcomponent %} + """ + ) + rendered = template.render(Context({})) + assert_dom_equal(rendered, '
Override
') + + def test_outer_slot_overriden(self): + component.registry.register("test", self.NestedComponent) + + template = Template( + """ + {% load viewcomponent_tags %} + {% component 'test' as component %}{% call component.outer %}

Override

{% endcall %}{% endcomponent %} + """ + ) + rendered = template.render(Context({})) + assert_dom_equal(rendered, "

Override

") + + def test_both_overriden_and_inner_removed(self): + component.registry.register("test", self.NestedComponent) + + template = Template( + """ + {% load viewcomponent_tags %} + {% component 'test' as component %} + {% call component.outer %}

Override

{% endcall %} + {% call component.inner %}

Will not appear

{% endcall %} + {% endcomponent %} + """ + ) + rendered = template.render(Context({})) + assert_dom_equal(rendered, "

Override

") + + +class TestConditionalSlot: + class ConditionalComponent(component.Component): + slot_a = RendersOneField() + slot_b = RendersOneField() + + template_name = "conditional_template.html" + + def __init__(self, branch=None, **kwargs): + self.branch = branch + + def test_no_content_if_branches_are_false(self): + component.registry.register("test", self.ConditionalComponent) + + template = Template( + """ + {% load viewcomponent_tags %} + {% component 'test' as component %} + {% call component.slot_a %}Override A{% endcall %} + {% call component.slot_b %}Override B{% endcall %} + {% endcomponent %} + """ + ) + rendered = template.render(Context({})) + assert_dom_equal(rendered, "") + + def test_default_content_if_no_slots(self): + component.registry.register("test", self.ConditionalComponent) + + template = Template( + """ + {% load viewcomponent_tags %} + {% component 'test' branch='a' %}{% endcomponent %} + {% component 'test' branch='b' %}{% endcomponent %} + """ + ) + rendered = template.render(Context({})) + assert_dom_equal(rendered, '

Default A

Default B

') + + def test_one_slot_overridden(self): + component.registry.register("test", self.ConditionalComponent) + + template = Template( + """ + {% load viewcomponent_tags %} + {% component 'test' branch='a' as component_1 %} + {% call component_1.slot_b %}Override B{% endcall %} + {% endcomponent %} + {% component 'test' branch='b' as component_2 %} + {% call component_2.slot_b %}Override B{% endcall %} + {% endcomponent %} + """ + ) + rendered = template.render(Context({})) + assert_dom_equal(rendered, '

Default A

Override B

') + + def test_both_slots_overridden(self): + component.registry.register("test", self.ConditionalComponent) + + template = Template( + """ + {% load viewcomponent_tags %} + {% component 'test' branch='a' as component_1 %} + {% call component_1.slot_a %}Override A{% endcall %} + {% call component_1.slot_b %}Override B{% endcall %} + {% endcomponent %} + {% component 'test' branch='b' as component_2 %} + {% call component_2.slot_a %}Override A{% endcall %} + {% call component_2.slot_b %}Override B{% endcall %} + {% endcomponent %} + """ + ) + rendered = template.render(Context({})) + assert_dom_equal(rendered, '

Override A

Override B

') + + +class TestTemplateSyntaxError: + @pytest.fixture(autouse=True) + def register_component(self): + component.registry.register("test", SlottedComponent) + + def test_variable(self): + Template( + """ + {% load viewcomponent_tags %} + {% component "test" %} + {{ anything }} + {% endcomponent %} + """ + ) + + def test_text(self): + Template( + """ + {% load viewcomponent_tags %} + {% component "test" %} + Text + {% endcomponent %} + """ + ) + + def test_block_outside_call(self): + Template( + """ + {% load viewcomponent_tags %} + {% component "test" as component %} + {% if True %} + {% call component.header %}{% endcall %} + {% endif %} + {% endcomponent %} + """ + ) + + def test_unclosed_component_is_error(self): + with pytest.raises(TemplateSyntaxError): + Template( + """ + {% load viewcomponent_tags %} + {% component "test" %} + {% call "header" %}{% endcall %} + """ + ) + + def test_fill_with_no_component_is_error(self): + with pytest.raises(ValueError): + Template( + """ + {% load viewcomponent_tags %} + {% call component.header %}contents{% endcall %} + """ + ).render(Context({})) + + +class TestComponentNesting: + @pytest.fixture(autouse=True) + def register_component(self): + component.registry.register("calendar", CalendarComponent) + component.registry.register("dashboard", DashboardComponent) + + def test_component_nesting_component_without_fill(self): + template = Template( + """ + {% load viewcomponent_tags %} + {% component "dashboard" as component %} + {% call component.header %} + Hello, User X + {% endcall %} + {% endcomponent %} + """ + ) + rendered = template.render(Context({"items": [1, 2, 3]})) + expected = """ +
+
+

+ Hello, User X +

+
+ Here are your to-do items for today: +
+
+
    +
  1. 1
  2. +
  3. 2
  4. +
  5. 3
  6. +
+
+ """ + assert_dom_equal(rendered, expected) + + +class TestConditionalIfFilledSlots: + class ComponentWithConditionalSlots(component.Component): + title = RendersOneField() + subtitle = RendersOneField() + + template_name = "template_with_conditional_slots.html" + + class ComponentWithComplexConditionalSlots(component.Component): + title = RendersOneField() + subtitle = RendersOneField() + alt_subtitle = RendersOneField() + + template_name = "template_with_if_elif_else_conditional_slots.html" + + @pytest.fixture(autouse=True) + def register_component(self): + component.registry.register( + "conditional_slots", self.ComponentWithConditionalSlots + ) + component.registry.register( + "complex_conditional_slots", + self.ComponentWithComplexConditionalSlots, + ) + + def test_simple_component_with_conditional_slot(self): + template = """ + {% load viewcomponent_tags %} + {% component "conditional_slots" %}{% endcomponent %} + """ + expected = """ +
+
+ Title +
+
+ """ + rendered = Template(template).render(Context({})) + assert_dom_equal(rendered, expected) + + def test_component_with_filled_conditional_slot(self): + template = """ + {% load viewcomponent_tags %} + {% component "conditional_slots" as component %} + {% call component.subtitle %} My subtitle {% endcall %} + {% endcomponent %} + """ + expected = """ +
+
+ Title +
+
+ My subtitle +
+
+ """ + rendered = Template(template).render(Context({})) + assert_dom_equal(rendered, expected) + + def test_elif_of_complex_conditional_slots(self): + template = """ + {% load viewcomponent_tags %} + {% component "complex_conditional_slots" as component %} + {% call component.alt_subtitle %} A different subtitle {% endcall %} + {% endcomponent %} + """ + expected = """ +
+
+ Title +
+
+ A different subtitle +
+
+ """ + rendered = Template(template).render(Context({})) + assert_dom_equal(rendered, expected) + + def test_else_of_complex_conditional_slots(self): + template = """ + {% load viewcomponent_tags %} + {% component "complex_conditional_slots" %} + {% endcomponent %} + """ + expected = """ +
+
+ Title +
+
Nothing filled!
+
+ """ + rendered = Template(template).render(Context({})) + assert_dom_equal(rendered, expected) + + +class TestComponentWithinBlock: + def test_block_and_extends_tag_works(self): + component.registry.register("slotted_component", SlottedComponent) + template = """ + {% extends "extendable_template_with_blocks.html" %} + {% load viewcomponent_tags %} + {% block body %} + {% component "slotted_component" as component %} + {% call component.header %}{% endcall %} + {% call component.main %} + TEST + {% endcall %} + {% call component.footer %}{% endcall %} + {% endcomponent %} + {% endblock %} + """ + rendered = Template(template).render(Context()) + expected = """ + + + +
+
+ +
+
TEST
+
+
+
+
+ + + """ + assert_dom_equal(rendered, expected) + + +class TestComponentCollectionSlots: + class TabsComponent(component.Component): + tabs = RendersManyField(required=True) + panels = RendersManyField(required=True) + + template = """ +
+ {% for tab in self.tabs.value %} +
+ {{ tab }} +
+ {% endfor %} +
+ +
+ {% for panel in self.panels.value %} +
+ {{ panel }} +
+ {% endfor %} +
+ """ + + @pytest.fixture(autouse=True) + def register_component(self): + component.registry.register("tabs", self.TabsComponent) + + def test_missing_required_slot_raises_error(self): + template = Template( + """ + {% load viewcomponent_tags %} + {% component 'tabs' %} + {% endcomponent %} + """ + ) + with pytest.raises(ValueError): + template.render(Context({})) + + def test_collection_basic(self): + template = Template( + """ + {% load viewcomponent_tags %} + {% component 'tabs' as component %} + {% call component.tabs %}Tab 1{% endcall %} + {% call component.tabs %}Tab 2{% endcall %} + {% call component.tabs %}Tab 3{% endcall %} + + {% call component.panels %}Panel 1{% endcall %} + {% call component.panels %}Panel 2{% endcall %} + {% call component.panels %}Panel 3{% endcall %} + {% endcomponent %} + """ + ) + rendered = template.render(Context({})) + assert_dom_equal( + """ +
+
+ Tab 1 +
+
+ Tab 2 +
+
+ Tab 3 +
+
+ +
+
+ Panel 1 +
+
+ Panel 2 +
+
+ Panel 3 +
+
+ """, + rendered, + ) + + def test_collection_variable(self): + template = Template( + """ + {% load viewcomponent_tags %} + {% with tab="Tab" panel="Panel" %} + {% component 'tabs' as component %} + {% call component.tabs %}{{ tab }} 1{% endcall %} + {% call component.tabs %}{{ tab }} 2{% endcall %} + {% call component.tabs %}{{ tab }} 3{% endcall %} + + {% call component.panels %}{{ panel }} 1{% endcall %} + {% call component.panels %}{{ panel }} 2{% endcall %} + {% call component.panels %}{{ panel }} 3{% endcall %} + {% endcomponent %} + {% endwith %} + """ + ) + rendered = template.render(Context({})) + assert_dom_equal( + """ +
+
+ Tab 1 +
+
+ Tab 2 +
+
+ Tab 3 +
+
+ +
+
+ Panel 1 +
+
+ Panel 2 +
+
+ Panel 3 +
+
+ """, + rendered, + ) + + +@pytest.mark.django_db +class TestFieldComponentParameter: + class HeaderComponent(component.Component): + def __init__(self, classes, **kwargs): + self.classes = classes + + template = """ +

+ {{ self.content }} +

+ """ + + class PostComponent(component.Component): + def __init__(self, post, **kwargs): + self.post = post + + template = """ + {% load viewcomponent_tags %} + +

{{ self.post.title }}

+
{{ self.post.description }}
+ """ + + class BlogComponent(component.Component): + header = RendersOneField(required=True, component="header") + posts = RendersManyField(required=True, component="post") + + template = """ + {% load viewcomponent_tags %} + {{ self.header.value }} + {% for post in self.posts.value %} + {{ post }} + {% endfor %} + """ + + @pytest.fixture(autouse=True) + def register_component(self): + component.registry.register("blog", self.BlogComponent) + component.registry.register("header", self.HeaderComponent) + component.registry.register("post", self.PostComponent) + + def test_field_component_parameter(self): + for i in range(5): + title = f"test {i}" + description = f"test {i}" + Post.objects.create(title=title, description=description) + + qs = Post.objects.all() + + 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 %} + """ + ) + rendered = template.render(Context({"qs": qs})) + expected = """ +

+ My Site +

+ +

test 0

+
test 0
+ +

test 1

+
test 1
+ +

test 2

+
test 2
+ +

test 3

+
test 3
+ +

test 4

+
test 4
+ """ + assert_dom_equal(expected, rendered) diff --git a/tests/testapp/__init__.py b/tests/testapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/testapp/apps.py b/tests/testapp/apps.py new file mode 100644 index 0000000..bf54ba2 --- /dev/null +++ b/tests/testapp/apps.py @@ -0,0 +1,7 @@ +# Django +from django.apps import AppConfig + + +class TestAppConfig(AppConfig): + name = "tests.testapp" + verbose_name = "TestApp" diff --git a/tests/testapp/models.py b/tests/testapp/models.py new file mode 100644 index 0000000..b17736b --- /dev/null +++ b/tests/testapp/models.py @@ -0,0 +1,7 @@ +# Django +from django.db import models + + +class Post(models.Model): + title = models.CharField(max_length=255) + description = models.TextField() diff --git a/tests/testapp/urls.py b/tests/testapp/urls.py new file mode 100644 index 0000000..f577ce2 --- /dev/null +++ b/tests/testapp/urls.py @@ -0,0 +1,7 @@ +# Django +from django.urls import path, include + + +urlpatterns = [ + path("previews/", include("django_viewcomponent.urls")) +] diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..9c62038 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,11 @@ +from bs4 import BeautifulSoup + + +def assert_dom_equal(expected_html, actual_html): + expected_soup = BeautifulSoup(expected_html, "html.parser") + actual_soup = BeautifulSoup(actual_html, "html.parser") + + expected_str = expected_soup.prettify() + actual_str = actual_soup.prettify() + + assert expected_str == actual_str diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..37686e9 --- /dev/null +++ b/tox.ini @@ -0,0 +1,31 @@ +[tox] +envlist = + py{38,39,310}-django32 + py{39,310}-django42 + py{310, py311}-django5 + +[testenv] +changedir=tests +deps = + django32: django>=3.2,<3.3 + django42: django>=3.3,<4.3 + django5: django>=5.0,<6.0 + typing_extensions + pytest + pytest-django + pytest-xdist + pytest-mock + bs4 + jinja2 +usedevelop = True +commands = + pytest {posargs} +setenv = + PYTHONDONTWRITEBYTECODE=1 + +[gh-actions] +python = + 3.8: py38 + 3.9: py39 + 3.10: py310 + 3.11: py311