feat: add JS dependency manager (#666)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Juro Oravec 2024-09-22 16:42:41 +02:00 committed by GitHub
parent 0026fa4a44
commit 849a869a66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1730 additions and 19 deletions

View file

@ -16,7 +16,7 @@ jobs:
steps:
- name: Checkout the repo
uses: actions/checkout@v2
- name: Setup python
uses: actions/setup-python@v2
with:

View file

@ -16,6 +16,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
@ -24,6 +25,8 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install tox tox-gh-actions
python -m pip install -r requirements-ci.txt
# See https://playwright.dev/python/docs/intro#installing-playwright-pytest
playwright install chromium --with-deps
- name: Run tests
run: tox

5
.gitignore vendored
View file

@ -50,6 +50,7 @@ coverage.xml
# Django stuff:
*.log
*.sqlite3
# Sphinx documentation
docs/_build/
@ -74,3 +75,7 @@ poetry.lock
.python-version
site
docs/reference
# JS, NPM Dependency directories
node_modules/
jspm_packages/

5
MANIFEST.in Normal file
View file

@ -0,0 +1,5 @@
# MANIFEST.in is defined so we can include non-Python (e.g. JS) files
# in the built distribution.
# See https://setuptools.pypa.io/en/latest/userguide/miscellaneous.html
graft src/django_components/static
prune tests

View file

@ -3852,7 +3852,7 @@ One of our goals with `django-components` is to make it easy to share components
- [django-htmx-components](https://github.com/iwanalabs/django-htmx-components): A set of components for use with [htmx](https://htmx.org/). Try out the [live demo](https://dhc.iwanalabs.com/).
## Running django-components project locally
## Contributing and development
### Install locally and run the tests
@ -3886,6 +3886,22 @@ pyenv local 3.8 3.9 3.10 3.11 3.12
tox -p
```
### Running Playwright tests
We use [Playwright](https://playwright.dev/python/docs/intro) for end-to-end tests. You will therefore need to install Playwright to be able to run these tests.
Luckily, Playwright makes it very easy:
```sh
pip install -r requirements-dev.txt
playwright install chromium --with-deps
```
After Playwright is ready, simply run the tests with `tox`:
```sh
tox
```
### Developing against live Django app
How do you check that your changes to django-components project will work in an actual Django project?
@ -3921,6 +3937,54 @@ Once the server is up, it should be available at <http://127.0.0.1:8000>.
To display individual components, add them to the `urls.py`, like in the case of <http://127.0.0.1:8000/greeting>
## Development guides
### Building JS code
- [Slot rendering flot](https://github.com/EmilStenstrom/django-components/blob/master/docs/slot_rendering.md)
django_components uses a bit of JS code to:
- Manage the loading of JS and CSS files used by the components
- Allow to pass data from Python to JS
When you make changes to this JS code, you also need to compile it:
1. Make sure you are inside `src/django_components_js`:
```sh
cd src/django_components_js
```
2. Install the JS dependencies
```sh
npm install
```
3. Compile the JS/TS code:
```sh
python build.py
```
The script will combine all JS/TS code into a single `.js` file, minify it,
and copy it to `django_components/static/django_components/django_components.min.js`.
### Packaging and publishing
To package the library into a distribution that can be published to PyPI, run:
```sh
# Install pypa/build
python -m pip install build --user
# Build a binary wheel and a source tarball
python -m build --sdist --wheel --outdir dist/ .
```
To publish the package to PyPI, use `twine` ([See Python user guide](https://packaging.python.org/en/latest/tutorials/packaging-projects/#uploading-the-distribution-archives)):
```sh
twine upload --repository pypi dist/* -u __token__ -p <PyPI_TOKEN>
```
[See the full workflow here.](https://github.com/EmilStenstrom/django-components/discussions/557#discussioncomment-10179141)
### Development guides
- [Slot rendering flot](https://github.com/EmilStenstrom/django-components/blob/master/docs/slot_rendering.md)
- [Slots and blocks](https://github.com/EmilStenstrom/django-components/blob/master/docs/slots_and_blocks.md)

6
requirements-ci.in Normal file
View file

@ -0,0 +1,6 @@
tox
tox-gh-actions
playwright
requests
types-requests
whitenoise

58
requirements-ci.txt Normal file
View file

@ -0,0 +1,58 @@
#
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile requirements-ci.in
#
cachetools==5.5.0
# via tox
certifi==2024.8.30
# via requests
chardet==5.2.0
# via tox
charset-normalizer==3.3.2
# via requests
colorama==0.4.6
# via tox
distlib==0.3.8
# via virtualenv
filelock==3.16.1
# via
# tox
# virtualenv
greenlet==3.0.3
# via playwright
idna==3.10
# via requests
packaging==24.1
# via
# pyproject-api
# tox
platformdirs==4.3.6
# via
# tox
# virtualenv
playwright==1.47.0
# via -r requirements-ci.in
pluggy==1.5.0
# via tox
pyee==12.0.0
# via playwright
pyproject-api==1.8.0
# via tox
requests==2.32.3
# via -r requirements-ci.in
tox==4.20.0
# via
# -r requirements-ci.in
# tox-gh-actions
tox-gh-actions==3.2.0
# via -r requirements-ci.in
typing-extensions==4.12.2
# via pyee
urllib3==2.2.3
# via requests
virtualenv==20.26.5
# via tox
whitenoise==6.7.0
# via -r requirements-ci.in

View file

@ -6,4 +6,8 @@ flake8-pyproject
isort
pre-commit
black
mypy
mypy
playwright
requests
types-requests
whitenoise

View file

@ -1,5 +1,5 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile requirements-dev.in
@ -10,10 +10,14 @@ black==24.8.0
# via -r requirements-dev.in
cachetools==5.4.0
# via tox
certifi==2024.8.30
# via requests
cfgv==3.4.0
# via pre-commit
chardet==5.2.0
# via tox
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via black
colorama==0.4.6
@ -32,8 +36,12 @@ flake8==7.1.1
# flake8-pyproject
flake8-pyproject==1.2.3
# via -r requirements-dev.in
greenlet==3.0.3
# via playwright
identify==2.5.33
# via pre-commit
idna==3.10
# via requests
iniconfig==2.0.0
# via pytest
isort==5.13.2
@ -61,6 +69,8 @@ platformdirs==4.2.2
# black
# tox
# virtualenv
playwright==1.47.0
# via -r requirements-dev.in
pluggy==1.5.0
# via
# pytest
@ -69,6 +79,8 @@ pre-commit==3.8.0
# via -r requirements-dev.in
pycodestyle==2.12.0
# via flake8
pyee==12.0.0
# via playwright
pyflakes==3.2.0
# via flake8
pyproject-api==1.7.1
@ -77,16 +89,28 @@ pytest==8.3.3
# via -r requirements-dev.in
pyyaml==6.0.1
# via pre-commit
requests==2.32.3
# via -r requirements-dev.in
sqlparse==0.5.0
# via django
tox==4.18.0
# via -r requirements-dev.in
types-requests==2.32.0.20240914
# via -r requirements-dev.in
typing-extensions==4.10.0
# via mypy
# via
# mypy
# pyee
urllib3==2.2.3
# via
# requests
# types-requests
virtualenv==20.26.3
# via
# pre-commit
# tox
whitenoise==6.7.0
# via -r requirements-dev.in
# The following packages are considered to be unsafe in a requirements file:
# setuptools

View file

@ -0,0 +1 @@
(()=>{var y=Array.isArray,p=t=>typeof t=="function",M=t=>t!==null&&typeof t=="object",h=t=>(M(t)||p(t))&&p(t.then)&&p(t.catch);function S(t,a){try{return a?t.apply(null,a):t()}catch(r){f(r)}}function m(t,a){if(p(t)){let r=S(t,a);return r&&h(r)&&r.catch(i=>{f(i)}),[r]}if(y(t)){let r=[];for(let i=0;i<t.length;i++)r.push(m(t[i],a));return r}else console.warn(`[Components] Invalid value type passed to callWithAsyncErrorHandling(): ${typeof t}`)}function f(t){console.error(t)}var u=()=>{let t=new Set,a=new Set,r={},i={},C=e=>{let n=new DOMParser().parseFromString(e,"text/html").querySelector("script");if(!n)throw Error("[Components] Failed to extract <script> tag. Make sure that the string contains <script><\/script> and is a valid HTML");return n},x=e=>{let n=new DOMParser().parseFromString(e,"text/html").querySelector("link");if(!n)throw Error("[Components] Failed to extract <link> tag. Make sure that the string contains <link></link> and is a valid HTML");return n},g=e=>{let n=document.createElement(e.tagName);for(let o of e.attributes)n.setAttributeNode(o.cloneNode());return n};return{callComponent:(e,n,o)=>{let s=r[e];if(!s)throw Error(`[Components] '${e}': No component registered for that name`);let c=Array.from(document.querySelectorAll(`[data-comp-id-${n}]`));if(!c.length)throw Error(`[Components] '${e}': No elements with component ID '${n}' found`);let l=`${e}:${o}`,d=i[l];if(!d)throw Error(`[Components] '${e}': Cannot find input for hash '${o}'`);let T=d(),E={name:e,id:n,els:c},[F]=m(s,[T,E]);return F},registerComponent:(e,n)=>{r[e]=n},registerComponentData:(e,n,o)=>{let s=`${e}:${n}`;i[s]=o},loadScript:(e,n)=>{if(e==="js"){let o=C(n),s=o.getAttribute("src");if(!s||t.has(s))return;t.add(s);let c=g(o);return new Promise((l,d)=>{c.onload=()=>{l()},globalThis.document.body.append(c)})}else if(e==="css"){let o=x(n),s=o.getAttribute("href");if(!s||a.has(s))return;let c=g(o);return globalThis.document.head.append(c),a.add(s),Promise.resolve()}else throw Error(`[Components] loadScript received invalid script type '${e}'. Must be one of 'js', 'css'`)},markScriptLoaded:(e,n)=>{if(e==="js")t.add(n);else if(e==="css")a.add(n);else throw Error(`[Components] markScriptLoaded received invalid script type '${e}'. Must be one of 'js', 'css'`)}}};var w={manager:u(),createComponentsManager:u,unescapeJs:r=>new DOMParser().parseFromString(r,"text/html").documentElement.textContent};globalThis.Components=w;})();

View file

@ -0,0 +1,62 @@
# Django componnets JS
## Usage
```js
// Register a function that is run at component initialization
Components.manager.registerComponent(
"table", // Component name
async (data, { id, name, els }) => {
...
},
);
// Register data factory function that may be used by multiple
// components.
Components.registerComponentData(
"table", // Component name
"3d09cf", // Input ID
() => {
return JSON.parse('{ "abc": 123 }');
},
);
// Once the component and data factories are registered,
// we can run component's init function
Components.callComponent(
"table", // Component name
12345, // Component ID - An HTML element with corresponding
// attribute (`data-comp-id-12345`) MUST
// be present in the DOM.
"3d09cf", // Input ID
);
// Load JS or CSS script if not laoded already
Components.loadScript("js", '<script src="/abc/def">');
// Or mark one as already-loaded, so it is ignored when
// we call `loadScript`
Components.markScriptLoaded("js", '/abc/def');
```
## Build
1. Make sure you are inside `django_components_js`:
```sh
cd src/django_components_js
```
2. Make sure that JS dependencies are installed
```sh
npm install
```
3. Compile the JS/TS code:
```sh
python build.py
```
This will copy it to `django_components/static/django_components/django_components.min.js`.

View file

@ -0,0 +1,54 @@
import os
import subprocess
from pathlib import Path
from typing import List, Optional, Sequence, Union
DEFAULT_ESBUILD_ARGS = [
"--bundle",
"--minify",
# "--sourcemap", # NOTE: Enable for debugging during development
"--platform=browser",
"--target=chrome80,firefox73,safari13,edge80",
]
# Compile a list of JS/TS files into a single minified file with esbuild
def compile_js_files_to_file(
file_paths: Sequence[Union[Path, str]],
out_file: Union[Path, str],
esbuild_args: Optional[List[str]] = None,
):
# Find Esbuild binary
bin_name = "esbuild.cmd" if os.name == "nt" else "esbuild"
esbuild_path = Path(os.getcwd()) / "node_modules" / ".bin" / bin_name
# E.g. `esbuild js_file1.ts js_file2.ts js_file3.ts --bundle --minify --outfile=here.js`
esbuild_cmd = [
str(esbuild_path),
*[str(filepath) for filepath in file_paths],
*(esbuild_args if esbuild_args is not None else DEFAULT_ESBUILD_ARGS),
# Compile into a single file
f"--outfile={out_file}",
]
# check=True should ensure that this raises an error if the subprocess fails.
subprocess.run(esbuild_cmd, check=True)
# NOTE:
# - This script should be called from within django_components_js` dir!
# - Also you need to have esbuild installed. If not yet, run:
# `npm install -D esbuild`
def build():
entrypoint = "./src/index.ts"
out_file = Path("../django_components/static/django_components/django_components.min.js")
# Prepare output dir
os.makedirs(out_file.parent, exist_ok=True)
# Compile JS
compile_js_files_to_file(file_paths=[entrypoint], out_file=out_file)
if __name__ == "__main__":
build()

435
src/django_components_js/package-lock.json generated Normal file
View file

@ -0,0 +1,435 @@
{
"name": "django_components_js",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"esbuild": "^0.23.1"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz",
"integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz",
"integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz",
"integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz",
"integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz",
"integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz",
"integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz",
"integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz",
"integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz",
"integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz",
"integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz",
"integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz",
"integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz",
"integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz",
"integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz",
"integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz",
"integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz",
"integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz",
"integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz",
"integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz",
"integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz",
"integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz",
"integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz",
"integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz",
"integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz",
"integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.23.1",
"@esbuild/android-arm": "0.23.1",
"@esbuild/android-arm64": "0.23.1",
"@esbuild/android-x64": "0.23.1",
"@esbuild/darwin-arm64": "0.23.1",
"@esbuild/darwin-x64": "0.23.1",
"@esbuild/freebsd-arm64": "0.23.1",
"@esbuild/freebsd-x64": "0.23.1",
"@esbuild/linux-arm": "0.23.1",
"@esbuild/linux-arm64": "0.23.1",
"@esbuild/linux-ia32": "0.23.1",
"@esbuild/linux-loong64": "0.23.1",
"@esbuild/linux-mips64el": "0.23.1",
"@esbuild/linux-ppc64": "0.23.1",
"@esbuild/linux-riscv64": "0.23.1",
"@esbuild/linux-s390x": "0.23.1",
"@esbuild/linux-x64": "0.23.1",
"@esbuild/netbsd-x64": "0.23.1",
"@esbuild/openbsd-arm64": "0.23.1",
"@esbuild/openbsd-x64": "0.23.1",
"@esbuild/sunos-x64": "0.23.1",
"@esbuild/win32-arm64": "0.23.1",
"@esbuild/win32-ia32": "0.23.1",
"@esbuild/win32-x64": "0.23.1"
}
}
}
}

View file

@ -0,0 +1,6 @@
{
"private": true,
"devDependencies": {
"esbuild": "^0.23.1"
}
}

View file

@ -0,0 +1,45 @@
/**
* This file contains code for safe calling of functions, as seen in Vue and Alpine.
* It ensures that when we run JS code for individual components, that the process continues
* even if the user-provided JS raises an error.
*/
import { isArray, isFunction, isPromise } from './utils';
type Fn = (...args: any[]) => any;
export function callWithErrorHandling(fn: Fn, args?: any[]) {
try {
return args ? fn.apply(null, args) : fn();
} catch (err) {
logError(err);
}
}
export function callWithAsyncErrorHandling(fn: Fn | Fn[], args?: any[]): any {
if (isFunction(fn)) {
const res = callWithErrorHandling(fn, args);
if (res && isPromise(res)) {
res.catch((err) => {
logError(err);
});
}
return [res];
}
if (isArray(fn)) {
const values: any[] = [];
for (let i = 0; i < fn.length; i++) {
values.push(callWithAsyncErrorHandling(fn[i], args));
}
return values;
} else {
console.warn(
`[Components] Invalid value type passed to callWithAsyncErrorHandling(): ${typeof fn}`
);
}
}
function logError(err: unknown) {
// recover in prod to reduce the impact on end-user
console.error(err);
}

View file

@ -0,0 +1,22 @@
/** This file defines the API of the JS code. */
import { createComponentsManager } from './manager';
export type * from './manager';
export const Components = (() => {
const manager = createComponentsManager();
/** Unescape JS that was escaped in Django side with `escape_js` */
const unescapeJs = (escapedJs: string) => {
return new DOMParser().parseFromString(escapedJs, 'text/html').documentElement.textContent;
};
return {
manager,
createComponentsManager,
unescapeJs,
};
})();
// In browser, this is accessed as `Components.manager`, etc
globalThis.Components = Components;

View file

@ -0,0 +1,196 @@
/** The actual code of the JS dependency manager */
import { callWithAsyncErrorHandling } from './errorHandling';
type MaybePromise<T> = Promise<T> | T;
export interface ComponentContext<
TEl extends HTMLElement = HTMLElement,
> {
name: string;
id: string;
els: TEl[];
}
export type ComponentFn<
TData extends object = object,
TEl extends HTMLElement = HTMLElement
> = (
data: TData,
ctx: ComponentContext<TEl>
) => MaybePromise<any>;
export type DataFn = () => object;
export type ScriptType = 'js' | 'css';
/**
* Usage:
*
* ```js
* Components.registerComponent("table", async (data, { id, name, els }) => {
* ...
* });
* ```
*
* ```js
* Components.registerComponentData("table", "3d09cf", () => {
* return JSON.parse('{ "abc": 123 }');
* });
* ```
*
* ```js
* Components.callComponent("table", 12345, "3d09cf");
* ```
*
* ```js
* Components.loadScript("js", '<script src="/abc/def"></script>');
* ```
*
* ```js
* Components.markScriptLoaded("js", '/abc/def');
* ```
*/
export const createComponentsManager = () => {
const loadedJs = new Set<string>();
const loadedCss = new Set<string>();
const components: Record<string, ComponentFn> = {};
const componentInputs: Record<string, DataFn> = {};
const parseScriptTag = (tag: string) => {
const scriptNode = new DOMParser().parseFromString(tag, 'text/html').querySelector('script');
if (!scriptNode) {
throw Error(
'[Components] Failed to extract <script> tag. Make sure that the string contains' +
' <script></script> and is a valid HTML'
);
}
return scriptNode;
};
const parseLinkTag = (tag: string) => {
const linkNode = new DOMParser().parseFromString(tag, 'text/html').querySelector('link');
if (!linkNode) {
throw Error(
'[Components] Failed to extract <link> tag. Make sure that the string contains' +
' <link></link> and is a valid HTML'
);
}
return linkNode;
};
// NOTE: The way we turn the string into an HTMLElement, if we then try to
// insert the node into the Document, it will NOT load. So instead we create
// a <script> that that WILL load once inserted, and copy all attributes from
// one to the other.
// Might be related to https://security.stackexchange.com/a/240362/302733
// See https://stackoverflow.com/questions/13121948
const cloneNode = (srcNode: HTMLElement) => {
const targetNode = document.createElement(srcNode.tagName);
for (const attr of srcNode.attributes) {
targetNode.setAttributeNode(attr.cloneNode() as Attr);
}
return targetNode;
};
const loadScript = (type: ScriptType, tag: string) => {
if (type === 'js') {
const srcScriptNode = parseScriptTag(tag);
// Use `.getAttribute()` instead of `.src` so we get the value as is,
// without the host name prepended if URL is just a path.
const src = srcScriptNode.getAttribute('src');
if (!src || loadedJs.has(src)) return;
loadedJs.add(src);
const targetScriptNode = cloneNode(srcScriptNode);
// In case of JS scripts, we return a Promise that resolves when the script is loaded
// See https://stackoverflow.com/a/57267538/9788634
return new Promise<void>((resolve, reject) => {
targetScriptNode.onload = () => {
resolve();
};
// Insert the script at the end of <body> to follow convention
globalThis.document.body.append(targetScriptNode);
});
} else if (type === 'css') {
const linkNode = parseLinkTag(tag);
// NOTE: Use `.getAttribute()` instead of `.href` so we get the value as is,
// without the host name prepended if URL is just a path.
const href = linkNode.getAttribute('href');
if (!href || loadedCss.has(href)) return;
// Insert at the end of <head> to follow convention
const targetLinkNode = cloneNode(linkNode);
globalThis.document.head.append(targetLinkNode);
loadedCss.add(href);
// For CSS, we return a dummy Promise, since we don't need to wait for anything
return Promise.resolve();
} else {
throw Error(
`[Components] loadScript received invalid script type '${type}'. Must be one of 'js', 'css'`
);
}
};
const markScriptLoaded = (type: ScriptType, url: string) => {
if (type === 'js') {
loadedJs.add(url);
} else if (type === 'css') {
loadedCss.add(url);
} else {
throw Error(
`[Components] markScriptLoaded received invalid script type '${type}'. Must be one of 'js', 'css'`
);
}
};
const registerComponent = (name: string, compFn: ComponentFn) => {
components[name] = compFn;
};
/**
* @example
* Components.registerComponentData("table", "a1b2c3", () => {{
* return JSON.parse('{ "a": 2 }');
* }});
*/
const registerComponentData = (name: string, inputHash: string, dataFactory: DataFn) => {
const key = `${name}:${inputHash}`;
componentInputs[key] = dataFactory;
};
const callComponent = (name: string, compId: string, inputHash: string): MaybePromise<any> => {
const initFn = components[name];
if (!initFn) throw Error(`[Components] '${name}': No component registered for that name`);
const elems = Array.from(document.querySelectorAll<HTMLElement>(`[data-comp-id-${compId}]`));
if (!elems.length) throw Error(`[Components] '${name}': No elements with component ID '${compId}' found`);
const dataKey = `${name}:${inputHash}`;
const dataFactory = componentInputs[dataKey];
if (!dataFactory) throw Error(`[Components] '${name}': Cannot find input for hash '${inputHash}'`);
const data = dataFactory();
const ctx = {
name,
id: compId,
els: elems,
} satisfies ComponentContext;
const [result] = callWithAsyncErrorHandling(initFn, [data, ctx] satisfies Parameters<ComponentFn>);
return result;
};
return {
callComponent,
registerComponent,
registerComponentData,
loadScript,
markScriptLoaded,
};
};

View file

@ -0,0 +1,15 @@
// Helper functions taken from @vue/shared
// See https://github.com/vuejs/core/blob/91112520427ff55941a1c759d7d60a0811ff4a61/packages/shared/src/general.ts#L105
export const isArray = Array.isArray;
export const isFunction = (val: unknown): val is Function => typeof val === 'function';
export const isObject = (val: unknown): val is Record<any, any> => {
return val !== null && typeof val === 'object';
};
export const isPromise = <T = any>(val: unknown): val is Promise<T> => {
return (
(isObject(val) || isFunction(val)) &&
isFunction((val as any).then) &&
isFunction((val as any).catch)
);
};

9
tests/conftest.py Normal file
View file

@ -0,0 +1,9 @@
import pytest
from tests.e2e.utils import run_django_dev_server
@pytest.fixture(scope="session", autouse=True)
def django_dev_server():
"""Fixture to run Django development server in the background."""
yield from run_django_dev_server()

View file

@ -5,14 +5,17 @@ import django
from django.conf import settings
def setup_test_config(components: Optional[Dict] = None):
def setup_test_config(
components: Optional[Dict] = None,
extra_settings: Optional[Dict] = None,
):
if settings.configured:
return
settings.configure(
BASE_DIR=Path(__file__).resolve().parent,
INSTALLED_APPS=("django_components", "tests.test_app"),
TEMPLATES=[
default_settings = {
"BASE_DIR": Path(__file__).resolve().parent,
"INSTALLED_APPS": ("django_components", "tests.test_app"),
"TEMPLATES": [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
@ -21,18 +24,25 @@ def setup_test_config(components: Optional[Dict] = None):
],
}
],
COMPONENTS={
"COMPONENTS": {
"template_cache_size": 128,
**(components or {}),
},
MIDDLEWARE=["django_components.middleware.ComponentDependencyMiddleware"],
DATABASES={
"MIDDLEWARE": ["django_components.middleware.ComponentDependencyMiddleware"],
"DATABASES": {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
},
SECRET_KEY="secret",
"SECRET_KEY": "secret",
}
settings.configure(
**{
**default_settings,
**(extra_settings or {}),
}
)
django.setup()

0
tests/e2e/__init__.py Normal file
View file

View file

22
tests/e2e/testserver/manage.py Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testserver.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,16 @@
"""
ASGI config for sampleproject project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testserver.settings")
application = get_asgi_application()

View file

@ -0,0 +1,106 @@
import os
import secrets
from pathlib import Path
from typing import List
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
WSGI_APPLICATION = "testserver.wsgi.application"
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get("SECRET_KEY", secrets.token_hex(100))
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS: List[str] = ["127.0.0.1", "localhost"]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_components",
]
# Application definition
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "testserver.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": False,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
"loaders": [
(
"django.template.loaders.cached.Loader",
[
"django.template.loaders.filesystem.Loader",
"django.template.loaders.app_directories.Loader",
"django_components.template_loader.Loader",
],
)
],
"builtins": [
"django_components.templatetags.component_tags",
],
},
},
]
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.0/howto/static-files/
STATIC_URL = "static/"
STATIC_ROOT = "staticfiles"
STATICFILES_FINDERS = [
# Default finders
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
# Django components
"django_components.finders.ComponentsFileSystemFinder",
]
# Database
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Default primary key field type
# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

View file

@ -0,0 +1,3 @@
from typing import List
urlpatterns: List = []

View file

@ -0,0 +1,16 @@
"""
WSGI config for testserver project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testserver.settings")
application = get_wsgi_application()

71
tests/e2e/utils.py Normal file
View file

@ -0,0 +1,71 @@
import functools
import subprocess
import time
from pathlib import Path
import requests
from playwright.async_api import async_playwright
TEST_SERVER_PORT = "8000"
TEST_SERVER_URL = f"http://127.0.0.1:{TEST_SERVER_PORT}"
# NOTE: Ideally we'd use Django's setUpClass and tearDownClass methods
# to instantiate the browser instance only once. But didn't have luck with that.
# So instead we have to create a browser instance for each test.
#
# Additionally, Django's documentation is lacking on async setUp and tearDown,
# so instead we use a decorator to run async code before/after each test.
def with_playwright(test_func):
"""Decorator that sets up and tears down Playwright browser instance."""
@functools.wraps(test_func)
async def wrapper(self, *args, **kwargs):
# Setup
self.playwright = await async_playwright().start()
self.browser = await self.playwright.chromium.launch()
# Test
await test_func(self, *args, **kwargs)
# Teardown
await self.browser.close()
await self.playwright.stop()
return wrapper
def run_django_dev_server():
"""Fixture to run Django development server in the background."""
# Get the path where testserver is defined, so the command doesn't depend
# on user's current working directory.
testserver_dir = (Path(__file__).parent / "testserver").absolute()
# Start the Django dev server in the background
print("Starting Django dev server...")
proc = subprocess.Popen(
["python", "manage.py", "runserver", f"127.0.0.1:{TEST_SERVER_PORT}", "--noreload"],
cwd=testserver_dir,
)
# Wait for the server to start by polling
start_time = time.time()
while time.time() - start_time < 30: # timeout after 30 seconds
try:
response = requests.get(f"http://127.0.0.1:{TEST_SERVER_PORT}")
if response.status_code == 200:
print("Django dev server is up and running.")
break
except requests.RequestException:
time.sleep(0.1)
else:
proc.terminate()
raise RuntimeError("Django server failed to start within the timeout period")
yield # Hand control back to the test session
# Teardown: Kill the server process after the tests
proc.terminate()
proc.wait()
print("Django dev server stopped.")

View file

@ -0,0 +1,442 @@
from typing import List
from django.test import override_settings
from playwright.async_api import Error, Page
from django_components import types
from tests.django_test_setup import setup_test_config
from tests.e2e.utils import TEST_SERVER_URL, with_playwright
from tests.testutils import BaseTestCase
setup_test_config(
components={"autodiscover": False},
extra_settings={
"ROOT_URLCONF": "tests.test_dependency_manager",
},
)
urlpatterns: List = []
class _BaseDepManagerTestCase(BaseTestCase):
async def _create_page_with_dep_manager(self) -> Page:
page = await self.browser.new_page()
# Load the JS library by opening a page with the script, and then running the script code
# E.g. `http://localhost:54017/static/django_components/django_components.min.js`
script_url = TEST_SERVER_URL + "/static/django_components/django_components.min.js"
await page.goto(script_url)
# The page's body is the script code. We load it by executing the code
await page.evaluate(
"""
() => {
eval(document.body.textContent);
}
"""
)
# Ensure the body is clear
await page.evaluate(
"""
() => {
document.body.innerHTML = '';
document.head.innerHTML = '';
}
"""
)
return page
@override_settings(STATIC_URL="static/")
class DependencyManagerTests(_BaseDepManagerTestCase):
@with_playwright
async def test_script_loads(self):
page = await self._create_page_with_dep_manager()
# Check the exposed API
keys = sorted(await page.evaluate("Object.keys(Components)"))
self.assertEqual(keys, ["createComponentsManager", "manager", "unescapeJs"])
keys = await page.evaluate("Object.keys(Components.manager)")
self.assertEqual(
keys, ["callComponent", "registerComponent", "registerComponentData", "loadScript", "markScriptLoaded"]
)
await page.close()
# Tests for `manager.loadScript()` / `manager.markAsLoaded()`
@override_settings(STATIC_URL="static/")
class LoadScriptTests(_BaseDepManagerTestCase):
@with_playwright
async def test_load_js_scripts(self):
page = await self._create_page_with_dep_manager()
# JS code that loads a few dependencies, capturing the HTML after each action
test_js: types.js = """() => {
const manager = Components.createComponentsManager();
const headBeforeFirstLoad = document.head.innerHTML;
// Adds a script the first time
manager.loadScript('js', "<script src='/one/two'></script>");
const bodyAfterFirstLoad = document.body.innerHTML;
// Does not add it the second time
manager.loadScript('js', "<script src='/one/two'></script>");
const bodyAfterSecondLoad = document.body.innerHTML;
// Adds different script
manager.loadScript('js', "<script src='/four/three'></script>");
const bodyAfterThirdLoad = document.body.innerHTML;
const headAfterThirdLoad = document.head.innerHTML;
return {
bodyAfterFirstLoad,
bodyAfterSecondLoad,
bodyAfterThirdLoad,
headBeforeFirstLoad,
headAfterThirdLoad,
};
}"""
data = await page.evaluate(test_js)
self.assertEqual(data["bodyAfterFirstLoad"], '<script src="/one/two"></script>')
self.assertEqual(data["bodyAfterSecondLoad"], '<script src="/one/two"></script>')
self.assertEqual(
data["bodyAfterThirdLoad"], '<script src="/one/two"></script><script src="/four/three"></script>'
)
self.assertEqual(data["headBeforeFirstLoad"], data["headAfterThirdLoad"])
self.assertEqual(data["headBeforeFirstLoad"], "")
await page.close()
@with_playwright
async def test_load_css_scripts(self):
page = await self._create_page_with_dep_manager()
# JS code that loads a few dependencies, capturing the HTML after each action
test_js: types.js = """() => {
const manager = Components.createComponentsManager();
const bodyBeforeFirstLoad = document.body.innerHTML;
// Adds a script the first time
manager.loadScript('css', "<link href='/one/two'>");
const headAfterFirstLoad = document.head.innerHTML;
// Does not add it the second time
manager.loadScript('css', "<link herf='/one/two'>");
const headAfterSecondLoad = document.head.innerHTML;
// Adds different script
manager.loadScript('css', "<link href='/four/three'>");
const headAfterThirdLoad = document.head.innerHTML;
const bodyAfterThirdLoad = document.body.innerHTML;
return {
headAfterFirstLoad,
headAfterSecondLoad,
headAfterThirdLoad,
bodyBeforeFirstLoad,
bodyAfterThirdLoad,
};
}"""
data = await page.evaluate(test_js)
self.assertEqual(data["headAfterFirstLoad"], '<link href="/one/two">')
self.assertEqual(data["headAfterSecondLoad"], '<link href="/one/two">')
self.assertEqual(data["headAfterThirdLoad"], '<link href="/one/two"><link href="/four/three">')
self.assertEqual(data["bodyBeforeFirstLoad"], data["bodyAfterThirdLoad"])
self.assertEqual(data["bodyBeforeFirstLoad"], "")
await page.close()
@with_playwright
async def test_does_not_load_script_if_marked_as_loaded(self):
page = await self._create_page_with_dep_manager()
# JS code that loads a few dependencies, capturing the HTML after each action
test_js: types.js = """() => {
const manager = Components.createComponentsManager();
// Adds a script the first time
manager.markScriptLoaded('css', '/one/two');
manager.markScriptLoaded('js', '/one/three');
manager.loadScript('css', "<link href='/one/two'>");
const headAfterFirstLoad = document.head.innerHTML;
manager.loadScript('js', "<script src='/one/three'></script>");
const bodyAfterSecondLoad = document.body.innerHTML;
return {
headAfterFirstLoad,
bodyAfterSecondLoad,
};
}"""
data = await page.evaluate(test_js)
self.assertEqual(data["headAfterFirstLoad"], "")
self.assertEqual(data["bodyAfterSecondLoad"], "")
await page.close()
# Tests for `manager.registerComponent()` / `registerComponentData()` / `callComponent()`
@override_settings(STATIC_URL="static/")
class CallComponentTests(_BaseDepManagerTestCase):
@with_playwright
async def test_calls_component_successfully(self):
page = await self._create_page_with_dep_manager()
test_js: types.js = """() => {
const manager = Components.createComponentsManager();
const compName = 'my_comp';
const compId = '12345';
const inputHash = 'input-abc';
// Pretend that this HTML belongs to our component
document.body.insertAdjacentHTML('beforeend', '<div data-comp-id-12345> abc </div>');
let captured = null;
manager.registerComponent(compName, (data, ctx) => {
captured = { ctx, data };
return 123;
});
manager.registerComponentData(compName, inputHash, () => {
return { hello: 'world' };
});
const result = manager.callComponent(compName, compId, inputHash);
// Serialize the HTML elements
captured.ctx.els = captured.ctx.els.map((el) => el.outerHTML);
return {
result,
captured,
};
}"""
data = await page.evaluate(test_js)
self.assertEqual(data["result"], 123)
self.assertEqual(
data["captured"],
{
"data": {
"hello": "world",
},
"ctx": {
"els": ['<div data-comp-id-12345=""> abc </div>'],
"id": "12345",
"name": "my_comp",
},
},
)
await page.close()
@with_playwright
async def test_calls_component_successfully_async(self):
page = await self._create_page_with_dep_manager()
test_js: types.js = """() => {
const manager = Components.createComponentsManager();
const compName = 'my_comp';
const compId = '12345';
const inputHash = 'input-abc';
// Pretend that this HTML belongs to our component
document.body.insertAdjacentHTML('beforeend', '<div data-comp-id-12345> abc </div>');
manager.registerComponent(compName, (data, ctx) => {
return Promise.resolve(123);
});
manager.registerComponentData(compName, inputHash, () => {
return { hello: 'world' };
});
// Should be Promise
const result = manager.callComponent(compName, compId, inputHash);
const isPromise = `${result}` === '[object Promise]';
// Wrap the whole response in Promise, so we can add extra fields
return Promise.resolve(result).then((res) => ({
result: res,
isPromise,
}));
}"""
data = await page.evaluate(test_js)
self.assertEqual(data["result"], 123)
self.assertEqual(data["isPromise"], True)
await page.close()
@with_playwright
async def test_error_in_component_call_do_not_propagate_sync(self):
page = await self._create_page_with_dep_manager()
test_js: types.js = """() => {
const manager = Components.createComponentsManager();
const compName = 'my_comp';
const compId = '12345';
const inputHash = 'input-abc';
// Pretend that this HTML belongs to our component
document.body.insertAdjacentHTML('beforeend', '<div data-comp-id-12345> abc </div>');
manager.registerComponent(compName, (data, ctx) => {
throw Error('Oops!');
return 123;
});
manager.registerComponentData(compName, inputHash, () => {
return { hello: 'world' };
});
const result = manager.callComponent(compName, compId, inputHash);
return result;
}"""
data = await page.evaluate(test_js)
self.assertEqual(data, None)
await page.close()
@with_playwright
async def test_error_in_component_call_do_not_propagate_async(self):
page = await self._create_page_with_dep_manager()
test_js: types.js = """() => {
const manager = Components.createComponentsManager();
const compName = 'my_comp';
const compId = '12345';
const inputHash = 'input-abc';
// Pretend that this HTML belongs to our component
document.body.insertAdjacentHTML('beforeend', '<div data-comp-id-12345> abc </div>');
manager.registerComponent(compName, async (data, ctx) => {
throw Error('Oops!');
return 123;
});
manager.registerComponentData(compName, inputHash, () => {
return { hello: 'world' };
});
const result = manager.callComponent(compName, compId, inputHash);
return Promise.allSettled([result]);
}"""
data = await page.evaluate(test_js)
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["status"], "rejected")
self.assertIsInstance(data[0]["reason"], Error)
self.assertEqual(data[0]["reason"].message, "Oops!")
await page.close()
@with_playwright
async def test_raises_if_component_element_not_in_dom(self):
page = await self._create_page_with_dep_manager()
test_js: types.js = """() => {
const manager = Components.createComponentsManager();
const compName = 'my_comp';
const compId = '12345';
const inputHash = 'input-abc';
manager.registerComponent(compName, (data, ctx) => {
return 123;
});
manager.registerComponentData(compName, inputHash, () => {
return { hello: 'world' };
});
// Should raise Error
manager.callComponent(compName, compId, inputHash);
}"""
with self.assertRaisesMessage(
Error, "Error: [Components] 'my_comp': No elements with component ID '12345' found"
):
await page.evaluate(test_js)
await page.close()
@with_playwright
async def test_raises_if_input_hash_not_registered(self):
page = await self._create_page_with_dep_manager()
test_js: types.js = """() => {
const manager = Components.createComponentsManager();
const compName = 'my_comp';
const compId = '12345';
const inputHash = 'input-abc';
document.body.insertAdjacentHTML('beforeend', '<div data-comp-id-12345> abc </div>');
manager.registerComponent(compName, (data, ctx) => {
return Promise.resolve(123);
});
// Should raise Error
manager.callComponent(compName, compId, inputHash);
}"""
with self.assertRaisesMessage(Error, "Error: [Components] 'my_comp': Cannot find input for hash 'input-abc'"):
await page.evaluate(test_js)
await page.close()
@with_playwright
async def test_raises_if_component_not_registered(self):
page = await self._create_page_with_dep_manager()
test_js: types.js = """() => {
const manager = Components.createComponentsManager();
const compName = 'my_comp';
const compId = '12345';
const inputHash = 'input-abc';
document.body.insertAdjacentHTML('beforeend', '<div data-comp-id-12345> abc </div>');
manager.registerComponentData(compName, inputHash, () => {
return { hello: 'world' };
});
// Should raise Error
manager.callComponent(compName, compId, inputHash);
}"""
with self.assertRaisesMessage(Error, "Error: [Components] 'my_comp': No component registered for that name"):
await page.evaluate(test_js)
await page.close()

15
tox.ini
View file

@ -30,6 +30,10 @@ deps =
django50: Django>=5.0,<5.1
pytest
pytest-xdist
playwright
requests
types-requests
whitenoise
commands = pytest {posargs}
[testenv:flake8]
@ -42,13 +46,20 @@ deps = isort
commands = isort --check-only --diff src/django_components
[testenv:coverage]
deps = pytest-coverage
deps =
pytest-coverage
playwright
requests
types-requests
whitenoise
commands =
coverage run --branch -m pytest
coverage report -m --fail-under=97
[testenv:mypy]
deps = mypy
deps =
mypy
types-requests
commands = mypy .
[testenv:black]