mirror of
https://github.com/django-components/django-components.git
synced 2025-07-07 17:34:59 +00:00
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:
parent
0026fa4a44
commit
849a869a66
31 changed files with 1730 additions and 19 deletions
2
.github/workflows/publish-to-pypi.yml
vendored
2
.github/workflows/publish-to-pypi.yml
vendored
|
@ -16,7 +16,7 @@ jobs:
|
|||
steps:
|
||||
- name: Checkout the repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
|
||||
- name: Setup python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
|
|
5
.github/workflows/tests.yml
vendored
5
.github/workflows/tests.yml
vendored
|
@ -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
5
.gitignore
vendored
|
@ -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
5
MANIFEST.in
Normal 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
|
70
README.md
70
README.md
|
@ -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
6
requirements-ci.in
Normal file
|
@ -0,0 +1,6 @@
|
|||
tox
|
||||
tox-gh-actions
|
||||
playwright
|
||||
requests
|
||||
types-requests
|
||||
whitenoise
|
58
requirements-ci.txt
Normal file
58
requirements-ci.txt
Normal 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
|
|
@ -6,4 +6,8 @@ flake8-pyproject
|
|||
isort
|
||||
pre-commit
|
||||
black
|
||||
mypy
|
||||
mypy
|
||||
playwright
|
||||
requests
|
||||
types-requests
|
||||
whitenoise
|
|
@ -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
|
||||
|
|
1
src/django_components/static/django_components/django_components.min.js
vendored
Normal file
1
src/django_components/static/django_components/django_components.min.js
vendored
Normal 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;})();
|
62
src/django_components_js/README.md
Normal file
62
src/django_components_js/README.md
Normal 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`.
|
54
src/django_components_js/build.py
Normal file
54
src/django_components_js/build.py
Normal 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
435
src/django_components_js/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
6
src/django_components_js/package.json
Normal file
6
src/django_components_js/package.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.23.1"
|
||||
}
|
||||
}
|
45
src/django_components_js/src/errorHandling.ts
Normal file
45
src/django_components_js/src/errorHandling.ts
Normal 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);
|
||||
}
|
22
src/django_components_js/src/index.ts
Normal file
22
src/django_components_js/src/index.ts
Normal 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;
|
196
src/django_components_js/src/manager.ts
Normal file
196
src/django_components_js/src/manager.ts
Normal 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,
|
||||
};
|
||||
};
|
15
src/django_components_js/src/utils.ts
Normal file
15
src/django_components_js/src/utils.ts
Normal 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
9
tests/conftest.py
Normal 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()
|
|
@ -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
0
tests/e2e/__init__.py
Normal file
0
tests/e2e/testserver/__init__.py
Normal file
0
tests/e2e/testserver/__init__.py
Normal file
22
tests/e2e/testserver/manage.py
Executable file
22
tests/e2e/testserver/manage.py
Executable 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()
|
0
tests/e2e/testserver/testserver/__init__.py
Normal file
0
tests/e2e/testserver/testserver/__init__.py
Normal file
16
tests/e2e/testserver/testserver/asgi.py
Normal file
16
tests/e2e/testserver/testserver/asgi.py
Normal 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()
|
106
tests/e2e/testserver/testserver/settings.py
Normal file
106
tests/e2e/testserver/testserver/settings.py
Normal 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"
|
3
tests/e2e/testserver/testserver/urls.py
Normal file
3
tests/e2e/testserver/testserver/urls.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from typing import List
|
||||
|
||||
urlpatterns: List = []
|
16
tests/e2e/testserver/testserver/wsgi.py
Normal file
16
tests/e2e/testserver/testserver/wsgi.py
Normal 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
71
tests/e2e/utils.py
Normal 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.")
|
442
tests/test_dependency_manager.py
Normal file
442
tests/test_dependency_manager.py
Normal 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
15
tox.ini
|
@ -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]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue