django-jsonsson 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_jsonsson-0.1.2/.github/workflows/ci.yaml +39 -0
- django_jsonsson-0.1.2/.github/workflows/release.yaml +74 -0
- django_jsonsson-0.1.2/.gitignore +7 -0
- django_jsonsson-0.1.2/AGENTS.md +121 -0
- django_jsonsson-0.1.2/LICENSE +21 -0
- django_jsonsson-0.1.2/Makefile +21 -0
- django_jsonsson-0.1.2/PKG-INFO +281 -0
- django_jsonsson-0.1.2/README.md +261 -0
- django_jsonsson-0.1.2/pyproject.toml +39 -0
- django_jsonsson-0.1.2/src/jsonsson/__init__.py +6 -0
- django_jsonsson-0.1.2/src/jsonsson/apps.py +6 -0
- django_jsonsson-0.1.2/src/jsonsson/static/jsonsson/css/list.css +70 -0
- django_jsonsson-0.1.2/src/jsonsson/static/jsonsson/js/list.js +117 -0
- django_jsonsson-0.1.2/src/jsonsson/templates/jsonsson/widgets/list.html +1 -0
- django_jsonsson-0.1.2/src/jsonsson/widgets.py +138 -0
- django_jsonsson-0.1.2/tests/__init__.py +0 -0
- django_jsonsson-0.1.2/tests/settings.py +34 -0
- django_jsonsson-0.1.2/tests/test_admin.py +282 -0
- django_jsonsson-0.1.2/tests/test_list_widget.py +146 -0
- django_jsonsson-0.1.2/tests/testapp/__init__.py +0 -0
- django_jsonsson-0.1.2/tests/testapp/admin.py +59 -0
- django_jsonsson-0.1.2/tests/testapp/models.py +11 -0
- django_jsonsson-0.1.2/tests/urls.py +4 -0
- django_jsonsson-0.1.2/uv.lock +401 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
13
|
+
cancel-in-progress: true
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
black:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v6
|
|
20
|
+
- uses: astral-sh/setup-uv@v6
|
|
21
|
+
- run: make lint
|
|
22
|
+
|
|
23
|
+
tests:
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
strategy:
|
|
26
|
+
matrix:
|
|
27
|
+
include:
|
|
28
|
+
- python: "3.10"
|
|
29
|
+
django: "4.2"
|
|
30
|
+
- python: "3.12"
|
|
31
|
+
django: "5.2"
|
|
32
|
+
- python: "3.14"
|
|
33
|
+
django: "6.0"
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/checkout@v6
|
|
36
|
+
- uses: astral-sh/setup-uv@v6
|
|
37
|
+
with:
|
|
38
|
+
python-version: ${{ matrix.python }}
|
|
39
|
+
- run: uv run --extra dev --with "django~=${{ matrix.django }}.0" pytest
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
env:
|
|
14
|
+
TAG: ${{ github.event.release.tag_name }}
|
|
15
|
+
steps:
|
|
16
|
+
# hatch-vcs reads the version from the git tag, so it needs the tags.
|
|
17
|
+
- name: Check out the tag
|
|
18
|
+
uses: actions/checkout@v6
|
|
19
|
+
with:
|
|
20
|
+
ref: ${{ env.TAG }}
|
|
21
|
+
fetch-depth: 0
|
|
22
|
+
|
|
23
|
+
- uses: astral-sh/setup-uv@v6
|
|
24
|
+
|
|
25
|
+
- name: Check the tag format
|
|
26
|
+
run: |
|
|
27
|
+
if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
28
|
+
echo "::error::Tag $TAG isn't in vX.Y.Z format"
|
|
29
|
+
exit 1
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
- name: Lint
|
|
33
|
+
run: make lint
|
|
34
|
+
|
|
35
|
+
- name: Test
|
|
36
|
+
run: uv run --extra dev pytest
|
|
37
|
+
|
|
38
|
+
- name: Build
|
|
39
|
+
run: uv build
|
|
40
|
+
|
|
41
|
+
# A dirty tree or a wrong checkout gives a .devN version instead.
|
|
42
|
+
- name: Check the built version matches the tag
|
|
43
|
+
run: ls "dist/django_jsonsson-${TAG#v}-py3-none-any.whl" "dist/django_jsonsson-${TAG#v}.tar.gz"
|
|
44
|
+
|
|
45
|
+
- name: Check the wheel has templates and static files
|
|
46
|
+
run: |
|
|
47
|
+
files=$(unzip -l dist/*.whl)
|
|
48
|
+
grep -q "jsonsson/templates/jsonsson/widgets/list.html" <<< "$files"
|
|
49
|
+
grep -q "jsonsson/static/jsonsson/js/list.js" <<< "$files"
|
|
50
|
+
grep -q "jsonsson/static/jsonsson/css/list.css" <<< "$files"
|
|
51
|
+
|
|
52
|
+
- uses: actions/upload-artifact@v4
|
|
53
|
+
with:
|
|
54
|
+
name: dist
|
|
55
|
+
path: dist/
|
|
56
|
+
|
|
57
|
+
# Separate job, so only the upload step gets the OIDC token.
|
|
58
|
+
# Uses PyPI trusted publishing, no API token is stored in the repo.
|
|
59
|
+
publish:
|
|
60
|
+
needs: build
|
|
61
|
+
runs-on: ubuntu-latest
|
|
62
|
+
environment:
|
|
63
|
+
name: pypi
|
|
64
|
+
url: https://pypi.org/p/django-jsonsson
|
|
65
|
+
permissions:
|
|
66
|
+
id-token: write
|
|
67
|
+
steps:
|
|
68
|
+
- uses: actions/download-artifact@v4
|
|
69
|
+
with:
|
|
70
|
+
name: dist
|
|
71
|
+
path: dist/
|
|
72
|
+
|
|
73
|
+
- name: Publish to PyPI
|
|
74
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# django-jsonsson - AI Agent Guide
|
|
2
|
+
|
|
3
|
+
Reusable Django form widgets that render `JSONField` values as plain, editable inputs. Install name `django-jsonsson`, import name `jsonsson`.
|
|
4
|
+
|
|
5
|
+
User-facing docs live in `README.md`. This file is for working on the library itself.
|
|
6
|
+
|
|
7
|
+
> **Self-update rule**: after finishing a task, ask "would a future agent get this wrong without knowing what I just learned?" If yes, update this file in the same changeset. Add gotchas, corrected assumptions, new build or release steps, new conventions. Don't add ticket-specific details or temporary workarounds. If the change is visible to users (new widget, new option, changed behavior), update `README.md` too.
|
|
8
|
+
|
|
9
|
+
## Tech Stack
|
|
10
|
+
|
|
11
|
+
| Layer | Technology |
|
|
12
|
+
|-------|-----------|
|
|
13
|
+
| Language | Python 3.10+ |
|
|
14
|
+
| Framework | Django 4.2+ (CI: 4.2, 5.2, 6.0) |
|
|
15
|
+
| Frontend | Plain ES5-style JS and CSS, no build step, no dependencies |
|
|
16
|
+
| Build backend | `hatchling` (src layout) |
|
|
17
|
+
| Package manager | `uv` |
|
|
18
|
+
| Tests | `pytest` + `pytest-django` |
|
|
19
|
+
| Formatter | `black` (enforced in CI) |
|
|
20
|
+
|
|
21
|
+
## Layout
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
src/jsonsson/
|
|
25
|
+
__init__.py # public API (re-exports widgets), __version__ from package metadata
|
|
26
|
+
apps.py # JsonssonConfig, only so templates/static are found
|
|
27
|
+
widgets.py # ListWidget
|
|
28
|
+
templates/jsonsson/widgets/list.html # ListWidget markup
|
|
29
|
+
static/jsonsson/js/list.js # builds inputs, syncs JSON back to the textarea
|
|
30
|
+
static/jsonsson/css/list.css # layout only
|
|
31
|
+
tests/
|
|
32
|
+
settings.py # Django settings for pytest (admin, sessions, messages)
|
|
33
|
+
urls.py # admin URLs, used by test_admin.py
|
|
34
|
+
testapp/ # sample ports editor in an admin inline (models + admin)
|
|
35
|
+
test_list_widget.py
|
|
36
|
+
test_admin.py # ListWidget through the real admin
|
|
37
|
+
.github/workflows/ci.yaml # black + pytest matrix
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## How a widget works
|
|
41
|
+
|
|
42
|
+
Every widget follows the same pattern. Keep it for new widgets.
|
|
43
|
+
|
|
44
|
+
1. **Python** (`widgets.py`): a `forms.Textarea` subclass. `get_context()` adds the `jsonsson-<widget>-source` class and `display:none` to the textarea, then puts the widget's options under `context["widget"]["jsonsson"]`.
|
|
45
|
+
2. **Template** (`templates/jsonsson/widgets/<widget>.html`): an outer container with the options as `data-*` attributes, the stock `django/forms/widgets/textarea.html` included inside it, plus any static controls (the add button).
|
|
46
|
+
3. **JS** (`static/jsonsson/js/<widget>.js`): finds containers, reads `data-*`, parses the textarea's JSON, builds inputs, and writes `JSON.stringify(...)` back into the textarea on every change.
|
|
47
|
+
4. **Backend**: Django only ever sees the textarea. The browser never coerces types. Converting and validating is the host form's `clean_<field>()`.
|
|
48
|
+
|
|
49
|
+
The Python -> template -> JS contract for `ListWidget`:
|
|
50
|
+
|
|
51
|
+
| Python (`widget.jsonsson`) | HTML | JS reads |
|
|
52
|
+
|---------------------------|------|----------|
|
|
53
|
+
| `block` | `jsonsson-list--block` class | (CSS only) |
|
|
54
|
+
| `min_items` | `data-min-items` | `editor.dataset.minItems` |
|
|
55
|
+
| `remove_label` | `data-remove-label` | `editor.dataset.removeLabel` |
|
|
56
|
+
| `input_attrs` (JSON string) | `data-input-attrs` | `JSON.parse(editor.dataset.inputAttrs)` |
|
|
57
|
+
| `add_label`, `add_text` | add button `title`/`aria-label`/text | (static markup) |
|
|
58
|
+
|
|
59
|
+
Changing any name means changing all three places, plus the README tables.
|
|
60
|
+
|
|
61
|
+
## Adding a new widget
|
|
62
|
+
|
|
63
|
+
1. Add the class to `widgets.py`, and export it from `__init__.py` (`__all__` too).
|
|
64
|
+
2. Add `templates/jsonsson/widgets/<name>.html`, `static/jsonsson/js/<name>.js`, `static/jsonsson/css/<name>.css`, and declare the assets in the widget's `Media`.
|
|
65
|
+
3. Prefix every CSS class and data attribute with `jsonsson-<name>`. Wrap the JS in an IIFE and use a namespaced init flag (`dataset.jsonssonInit`-style) so it's idempotent.
|
|
66
|
+
4. Handle `DOMContentLoaded` and `formset:added`, and skip elements whose source `name` contains `__prefix__` (the admin's empty formset template row).
|
|
67
|
+
5. Add tests (see below) and document it in `README.md` (widgets table, options, CSS classes, template context).
|
|
68
|
+
|
|
69
|
+
## Conventions
|
|
70
|
+
|
|
71
|
+
- **Namespacing**: all CSS classes, data attributes, template paths and static paths start with `jsonsson`. This is a library that lands in someone else's project, so nothing generic like `.list-editor` or `admin/js/...`.
|
|
72
|
+
- **Markup in templates, not Python strings**: users override `jsonsson/widgets/*.html`. Escaping comes from template autoescape. Don't go back to `mark_safe(f"...")`.
|
|
73
|
+
- **CSS is layout only**: spacing and show/hide. Sizing and colors belong to the host project, which adds its own CSS via a subclass `Media` (Django merges it with ours).
|
|
74
|
+
- **Inputs never restrict typing**: generated inputs are always plain `<input type="text">`. `mode`/`value_type` are backend hints only and must never change rendering. Restrictions go through `input_attrs`, which the user sets on purpose.
|
|
75
|
+
- **Submitted values are always strings**: trimmed, empty ones dropped. Don't add type coercion in JS.
|
|
76
|
+
- **Option resolution order** (`ListWidget.__init__`): constructor kwarg, then class attribute, then the `MODES` preset, then the hardcoded default. `value_type` and `input_attrs` default to `None` on the class precisely so this fallback works. Keep it when adding options.
|
|
77
|
+
- **Mutable defaults**: `input_attrs` must end up a fresh dict per instance, including when it comes from a class attribute. `__init__` copies it, and `__deepcopy__` copies it again because Django deep-copies widgets per form (`Meta.widgets`) and `Widget.__deepcopy__` only copies `attrs`.
|
|
78
|
+
- **Public API** is whatever `jsonsson/__init__.py` exports, plus the template context keys and CSS class names documented in the README. Treat changes to any of them as breaking.
|
|
79
|
+
|
|
80
|
+
## Gotchas
|
|
81
|
+
|
|
82
|
+
- **Widget `attrs` merge**: `get_context()` merges `self.attrs` with the passed `attrs` *before* appending our class and style. Django's own `build_attrs()` lets passed attrs replace widget attrs, so without this merge a `class`/`style` given to the constructor is silently dropped. Covered by `test_render_keeps_caller_class_and_style`.
|
|
83
|
+
- **`input_attrs` are DOM properties**: `list.js` does `input[key] = value`, not `setAttribute()`. So keys must be JS property names (`maxLength`, `readOnly`). `maxlength` silently does nothing.
|
|
84
|
+
- **`null` value breaks the JS**: `forms.JSONField.prepare_value(None)` renders `null`, `JSON.parse` returns `null`, and `initEditor()` throws on `values.length`. Same for a JSON object. Documented as a README limitation. If you fix it, update the README too.
|
|
85
|
+
- **Admin icon path**: `list.css` points at `url(../../admin/img/icon-addlink.svg)`, relative to `static/jsonsson/css/`, so it resolves to `STATIC_URL/admin/img/...`. It only exists when `django.contrib.admin` is installed. `--border-color` (an admin CSS variable) has a `#ccc` fallback. Don't make the library depend on the admin beyond that.
|
|
86
|
+
- **Template lookup**: the widget template is found through the form renderer's app-dirs lookup, which is why `jsonsson` has to be in `INSTALLED_APPS`. Tests rely on that too (`tests/settings.py`).
|
|
87
|
+
- **`uv` picking the wrong venv**: if the shell has `VIRTUAL_ENV` set from another project, `uv` warns and ignores it. Run `unset VIRTUAL_ENV` if anything looks odd.
|
|
88
|
+
|
|
89
|
+
## Testing
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
make test # uv sync --extra dev + pytest
|
|
93
|
+
make lint # black --check
|
|
94
|
+
make fmt # black
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- Tests are pure Python: options and fallbacks, rendered HTML (classes, `data-*`, escaping, attrs merging), form round trip through `forms.JSONField`, `Media`, and that static files are found.
|
|
98
|
+
- **Admin integration**: `tests/testapp/admin.py` is a typical setup, a `PortsListWidget` on a `Service.ports` JSONField in an admin `TabularInline`. `tests/test_admin.py` covers it through the real admin: class-attribute options, subclass `Media` merging (host CSS after ours), `Meta.widgets` copies, rendering existing int lists and the `__prefix__` row, and posting string lists that `clean_ports()` turns into ints. If one of these fails, the change will likely break real projects. Don't loosen the test, either fix the change or treat it as breaking.
|
|
99
|
+
- There are **no JS tests**. Any change to `list.js` or `list.css` needs a manual browser check: add, remove, `min_items` hiding the remove buttons, the hidden textarea's value after each change, and a row added through an admin inline formset.
|
|
100
|
+
- Assert on the exact rendered markup or behavior, not just "renders without error". A wrong template branch still renders fine.
|
|
101
|
+
- To check an older Django version locally, like CI does:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
uv run --isolated --extra dev --python 3.12 --with "django~=4.2.0" pytest
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Release
|
|
108
|
+
|
|
109
|
+
Releases go to PyPI as `django-jsonsson`. The license is MIT (`LICENSE`, declared via `license`/`license-files` in `pyproject.toml`, which needs `hatchling>=1.27`).
|
|
110
|
+
|
|
111
|
+
The version is never written in the code. `hatch-vcs` takes it from the git tag at build time, and `__version__` reads it back from the installed package metadata. Between tags, builds get a `.devN` version (e.g. `0.1.2.dev3+g1a2b3c4`), which is expected.
|
|
112
|
+
|
|
113
|
+
1. Merge everything to `main`.
|
|
114
|
+
2. Publish a GitHub release tagged `v<version>` (e.g. `v0.2.0`) from `main`.
|
|
115
|
+
3. `.github/workflows/release.yaml` runs on `release: published`. It checks out the tag with full history (hatch-vcs needs the tags), fails if the tag isn't `vX.Y.Z`, runs lint and tests, builds with `uv build`, checks the built files have exactly the tag's version and the wheel has `templates/` and `static/`, then a separate `publish` job uploads the wheel and sdist to PyPI.
|
|
116
|
+
|
|
117
|
+
Publishing uses PyPI trusted publishing (OIDC), so there's no API token secret. It needs a trusted publisher on PyPI for this repo, workflow `release.yaml` and environment `pypi`, plus a `pypi` environment in the GitHub repo settings. If the repo, workflow file or environment is renamed, update the PyPI side too or the upload fails. A version can only be uploaded to PyPI once, so a failed release after upload needs a version bump, not a re-run.
|
|
118
|
+
|
|
119
|
+
A release created by another workflow with `GITHUB_TOKEN` won't trigger it (GitHub doesn't fire events for those). If you add such a workflow, call `release.yaml` through `workflow_call`.
|
|
120
|
+
|
|
121
|
+
Consumers install it with `pip install django-jsonsson` and pin versions as usual.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pavlo K.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
.PHONY: help install-dev test fmt lint build
|
|
2
|
+
|
|
3
|
+
.DEFAULT_GOAL := help
|
|
4
|
+
|
|
5
|
+
help: ## Show this help message
|
|
6
|
+
@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*##"}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
|
|
7
|
+
|
|
8
|
+
install-dev: ## Install all dependencies (including dev)
|
|
9
|
+
uv sync --extra dev
|
|
10
|
+
|
|
11
|
+
test: install-dev ## Run tests
|
|
12
|
+
uv run pytest
|
|
13
|
+
|
|
14
|
+
fmt: ## Format code with black
|
|
15
|
+
uv run --extra dev black src tests
|
|
16
|
+
|
|
17
|
+
lint: ## Check formatting (CI)
|
|
18
|
+
uv run --extra dev black --check src tests
|
|
19
|
+
|
|
20
|
+
build: ## Build sdist and wheel into dist/
|
|
21
|
+
uv build
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: django-jsonsson
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Django form widgets that turn JSONField values into plain, editable inputs.
|
|
5
|
+
Author: Pavlo K.
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Framework :: Django
|
|
9
|
+
Classifier: Framework :: Django :: 4.2
|
|
10
|
+
Classifier: Framework :: Django :: 5.2
|
|
11
|
+
Classifier: Framework :: Django :: 6.0
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: django>=4.2
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: black; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-django; extra == 'dev'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# Jsonsson
|
|
22
|
+
|
|
23
|
+
Django form widgets that show a `JSONField` value as plain, editable inputs instead of a raw JSON textarea.
|
|
24
|
+
|
|
25
|
+
Nobody likes typing `["80", "443"]` by hand into a textarea. With Jsonsson the user gets one input per value, a button to add more and a cross to remove one. The form still submits normal JSON, so your model, form field and validation don't change.
|
|
26
|
+
|
|
27
|
+
## How it works
|
|
28
|
+
|
|
29
|
+
The real form field is still there, rendered as a hidden `<textarea>` with the JSON value inside. A small script reads that JSON, builds the inputs from it, and writes the JSON back into the textarea on every change. Django never sees the generated inputs, only the textarea.
|
|
30
|
+
|
|
31
|
+
This means:
|
|
32
|
+
|
|
33
|
+
- Works with Django's own `forms.JSONField` and `models.JSONField`, no custom field needed.
|
|
34
|
+
- Validation and type conversion stay on the backend, in your form's `clean_<field>()`.
|
|
35
|
+
- No build step and no frontend dependencies. Plain JS and CSS.
|
|
36
|
+
|
|
37
|
+
## Widgets
|
|
38
|
+
|
|
39
|
+
| Widget | JSON shape | Example value |
|
|
40
|
+
|--------|------------|---------------|
|
|
41
|
+
| `ListWidget` | Flat list of scalar values | `["80", "443"]` |
|
|
42
|
+
|
|
43
|
+
More JSON shapes (objects, single values) are planned.
|
|
44
|
+
|
|
45
|
+
## Requirements
|
|
46
|
+
|
|
47
|
+
- Python 3.10+
|
|
48
|
+
- Django 4.2+
|
|
49
|
+
- `django.contrib.admin` is optional, but the bundled CSS uses its add icon and colors (see [Styling](#styling)).
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install django-jsonsson
|
|
55
|
+
# or
|
|
56
|
+
uv add django-jsonsson
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Add the app to `INSTALLED_APPS`, so Django can find its templates and static files:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
INSTALLED_APPS = [
|
|
63
|
+
# ...
|
|
64
|
+
"jsonsson",
|
|
65
|
+
]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The widget's JS and CSS are declared in its `Media`. The Django admin includes them automatically. In your own templates, render `{{ form.media }}` like for any other widget with assets.
|
|
69
|
+
|
|
70
|
+
## ListWidget
|
|
71
|
+
|
|
72
|
+
### Quick start
|
|
73
|
+
|
|
74
|
+
A model with a list field:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from django.db import models
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Service(models.Model):
|
|
81
|
+
name = models.CharField(max_length=100)
|
|
82
|
+
tags = models.JSONField(default=list, blank=True)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Use the widget in a form:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from django import forms
|
|
89
|
+
from jsonsson import ListWidget
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ServiceForm(forms.ModelForm):
|
|
93
|
+
class Meta:
|
|
94
|
+
model = Service
|
|
95
|
+
fields = "__all__"
|
|
96
|
+
widgets = {"tags": ListWidget()}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Use that form in the admin:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from django.contrib import admin
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@admin.register(Service)
|
|
106
|
+
class ServiceAdmin(admin.ModelAdmin):
|
|
107
|
+
form = ServiceForm
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Or use the widget for every `JSONField` in a model admin:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from django.db import models
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ServiceAdmin(admin.ModelAdmin):
|
|
117
|
+
formfield_overrides = {models.JSONField: {"widget": ListWidget}}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Each value gets its own `<input type="text">` with a remove button, and there's an "Add item" button at the end.
|
|
121
|
+
|
|
122
|
+
### Options
|
|
123
|
+
|
|
124
|
+
Set them as constructor kwargs, or as class attributes on a subclass. Kwargs win over class attributes.
|
|
125
|
+
|
|
126
|
+
| Option | Default | What it does |
|
|
127
|
+
|--------|---------|--------------|
|
|
128
|
+
| `mode` | `"text"` | Shortcut for `value_type`. Built in: `"text"` (string) and `"digits"` (number). |
|
|
129
|
+
| `value_type` | from `mode` | `"string"` or `"number"`. A hint for your backend code only, read it as `widget.value_type`. It doesn't change rendering or what the browser submits. |
|
|
130
|
+
| `input_attrs` | `{}` | Extra attributes set on every value input, e.g. `{"maxLength": 5, "placeholder": "Port"}`. |
|
|
131
|
+
| `min_items` | `0` | How many inputs are always on screen. Remove buttons hide when this count is reached. |
|
|
132
|
+
| `layout` | `"block"` | `"block"`: one item per row. `"inline"`: items wrap left to right, good for a table cell. |
|
|
133
|
+
| `add_label` | `"Add item"` | Accessible name (`title` and `aria-label`) of the add button. |
|
|
134
|
+
| `add_text` | `"Add item"` | Visible text of the add button. `""` shows only the icon. |
|
|
135
|
+
| `remove_label` | `"Remove item"` | Accessible name of each remove button. |
|
|
136
|
+
|
|
137
|
+
Standard widget `attrs` work too. A `class` or `style` you pass is added to the hidden textarea next to the widget's own.
|
|
138
|
+
|
|
139
|
+
`input_attrs` keys are set as DOM properties, not HTML attributes. Use the JS names: `maxLength`, `readOnly`, `inputMode`, not `maxlength`, `readonly`, `inputmode`.
|
|
140
|
+
|
|
141
|
+
### Reusable subclasses
|
|
142
|
+
|
|
143
|
+
When the same setup is used in several places, put it in a subclass:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
class PortsWidget(ListWidget):
|
|
147
|
+
mode = "digits"
|
|
148
|
+
layout = "inline"
|
|
149
|
+
min_items = 1
|
|
150
|
+
input_attrs = {"maxLength": 5}
|
|
151
|
+
add_label = "Add port"
|
|
152
|
+
add_text = ""
|
|
153
|
+
remove_label = "Remove port"
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Values are always strings
|
|
157
|
+
|
|
158
|
+
Whatever the mode, the browser submits a JSON array of strings. Each value is trimmed and empty inputs are dropped. So `[" 80", "", "443 "]` on screen becomes `["80", "443"]`.
|
|
159
|
+
|
|
160
|
+
Converting and checking values is your form's job. `mode` and `value_type` only tell your code what to expect:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
class ServiceForm(forms.ModelForm):
|
|
164
|
+
class Meta:
|
|
165
|
+
model = Service
|
|
166
|
+
fields = "__all__"
|
|
167
|
+
widgets = {"ports": PortsWidget()}
|
|
168
|
+
|
|
169
|
+
def clean_ports(self):
|
|
170
|
+
cleaned = []
|
|
171
|
+
for item in self.cleaned_data.get("ports") or []:
|
|
172
|
+
try:
|
|
173
|
+
port = int(item)
|
|
174
|
+
except (TypeError, ValueError):
|
|
175
|
+
raise forms.ValidationError(f"'{item}' is not a valid port number.")
|
|
176
|
+
if not 1 <= port <= 65535:
|
|
177
|
+
raise forms.ValidationError(f"Port {port} must be between 1 and 65535.")
|
|
178
|
+
cleaned.append(port)
|
|
179
|
+
return cleaned
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The inputs never limit what the user can type. If you want a limit in the browser, add it yourself through `input_attrs`.
|
|
183
|
+
|
|
184
|
+
### Custom modes
|
|
185
|
+
|
|
186
|
+
Add a mode with a `MODES` dict on a subclass:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
class MyListWidget(ListWidget):
|
|
190
|
+
MODES = {
|
|
191
|
+
**ListWidget.MODES,
|
|
192
|
+
"decimal": {"value_type": "decimal"},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
MyListWidget(mode="decimal").value_type # "decimal"
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
An unknown mode falls back to `value_type="string"`.
|
|
200
|
+
|
|
201
|
+
### Inline formsets
|
|
202
|
+
|
|
203
|
+
Widgets in rows added with the admin's "Add another" button work too. The script listens for Django's `formset:added` event and skips the hidden `__prefix__` template row.
|
|
204
|
+
|
|
205
|
+
If you add rows with your own JS, dispatch the same event from the new row, so the widget gets set up:
|
|
206
|
+
|
|
207
|
+
```js
|
|
208
|
+
row.dispatchEvent(new CustomEvent("formset:added", { bubbles: true }));
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Styling
|
|
212
|
+
|
|
213
|
+
The bundled CSS (`jsonsson/css/list.css`) only does layout: spacing, the remove cross inside the input, hiding remove buttons at `min_items`. It looks native in the Django admin because it uses the admin's `admin/img/icon-addlink.svg` icon and `--border-color` variable. Outside the admin it still works. The add button just has no icon, and the hover color falls back to `#ccc`.
|
|
214
|
+
|
|
215
|
+
Sizing is up to you. Add your own CSS through the widget's `Media`:
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
class PortsWidget(ListWidget):
|
|
219
|
+
class Media:
|
|
220
|
+
css = {"all": ("myapp/css/ports.css",)}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
```css
|
|
224
|
+
.jsonsson-list .jsonsson-list-value {
|
|
225
|
+
width: 5ch;
|
|
226
|
+
box-sizing: content-box;
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Django merges a subclass's `Media` with its parent's, so the bundled JS and CSS are still included.
|
|
231
|
+
|
|
232
|
+
### CSS classes
|
|
233
|
+
|
|
234
|
+
| Class | Element |
|
|
235
|
+
|-------|---------|
|
|
236
|
+
| `jsonsson-list` | Outer container |
|
|
237
|
+
| `jsonsson-list--block` | Outer container, when `layout="block"` |
|
|
238
|
+
| `jsonsson-list-source` | The hidden textarea with the real value |
|
|
239
|
+
| `jsonsson-list-items` | Wrapper around items and the add button |
|
|
240
|
+
| `jsonsson-list-items--at-min` | Same wrapper, when the item count is at `min_items` |
|
|
241
|
+
| `jsonsson-list-item` | One item (input plus remove button) |
|
|
242
|
+
| `jsonsson-list-value` | The input of one item |
|
|
243
|
+
| `jsonsson-list-remove` | Remove button of one item |
|
|
244
|
+
| `jsonsson-list-add` | Add button |
|
|
245
|
+
| `jsonsson-list-add--icon-only` | Add button, when `add_text=""` |
|
|
246
|
+
|
|
247
|
+
### Custom markup
|
|
248
|
+
|
|
249
|
+
The HTML comes from the `jsonsson/widgets/list.html` template. Override it in your project's templates to change it. Keep the CSS classes and `data-*` attributes, the script depends on them.
|
|
250
|
+
|
|
251
|
+
With Django's default form renderer, templates are looked up in installed apps, so put the override in an app listed before `jsonsson` in `INSTALLED_APPS`. To use project-level `TEMPLATES` dirs, set `FORM_RENDERER = "django.forms.renderers.TemplatesSetting"`.
|
|
252
|
+
|
|
253
|
+
The template gets the usual widget context, plus `widget.jsonsson`:
|
|
254
|
+
|
|
255
|
+
| Key | Value |
|
|
256
|
+
|-----|-------|
|
|
257
|
+
| `block` | `True` when `layout="block"` |
|
|
258
|
+
| `min_items` | `min_items` as an int |
|
|
259
|
+
| `input_attrs` | `input_attrs` as a JSON string |
|
|
260
|
+
| `add_label`, `add_text`, `remove_label` | The labels |
|
|
261
|
+
|
|
262
|
+
## Limitations
|
|
263
|
+
|
|
264
|
+
- The field value must be a JSON array. `null` or an object breaks the script. For a model field, use `default=list`, and avoid `null=True` or make sure the value is never `None`.
|
|
265
|
+
- Only flat lists of scalar values. Nested lists and objects aren't supported yet.
|
|
266
|
+
- Values come back as strings (see above).
|
|
267
|
+
- Needs JavaScript. Without it the user sees nothing, since the textarea is hidden.
|
|
268
|
+
|
|
269
|
+
## Development
|
|
270
|
+
|
|
271
|
+
Uses [uv](https://docs.astral.sh/uv/).
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
make install-dev # install all dependencies
|
|
275
|
+
make test # run tests
|
|
276
|
+
make fmt # format with black
|
|
277
|
+
make lint # check formatting (CI)
|
|
278
|
+
make build # build sdist and wheel into dist/
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
CI runs `black --check` and the tests on Python 3.10 / Django 4.2, Python 3.12 / Django 5.2 and Python 3.14 / Django 6.0.
|