Metadata-Version: 2.5
Name: django-jsonsson
Version: 0.1.2
Summary: Django form widgets that turn JSONField values into plain, editable inputs.
Author: Pavlo K.
License-Expression: MIT
License-File: LICENSE
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.10
Requires-Dist: django>=4.2
Provides-Extra: dev
Requires-Dist: black; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: pytest-django; extra == 'dev'
Description-Content-Type: text/markdown

# Jsonsson

Django form widgets that show a `JSONField` value as plain, editable inputs instead of a raw JSON textarea.

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.

## How it works

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.

This means:

- Works with Django's own `forms.JSONField` and `models.JSONField`, no custom field needed.
- Validation and type conversion stay on the backend, in your form's `clean_<field>()`.
- No build step and no frontend dependencies. Plain JS and CSS.

## Widgets

| Widget | JSON shape | Example value |
|--------|------------|---------------|
| `ListWidget` | Flat list of scalar values | `["80", "443"]` |

More JSON shapes (objects, single values) are planned.

## Requirements

- Python 3.10+
- Django 4.2+
- `django.contrib.admin` is optional, but the bundled CSS uses its add icon and colors (see [Styling](#styling)).

## Install

```bash
pip install django-jsonsson
# or
uv add django-jsonsson
```

Add the app to `INSTALLED_APPS`, so Django can find its templates and static files:

```python
INSTALLED_APPS = [
    # ...
    "jsonsson",
]
```

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.

## ListWidget

### Quick start

A model with a list field:

```python
from django.db import models


class Service(models.Model):
    name = models.CharField(max_length=100)
    tags = models.JSONField(default=list, blank=True)
```

Use the widget in a form:

```python
from django import forms
from jsonsson import ListWidget


class ServiceForm(forms.ModelForm):
    class Meta:
        model = Service
        fields = "__all__"
        widgets = {"tags": ListWidget()}
```

Use that form in the admin:

```python
from django.contrib import admin


@admin.register(Service)
class ServiceAdmin(admin.ModelAdmin):
    form = ServiceForm
```

Or use the widget for every `JSONField` in a model admin:

```python
from django.db import models


class ServiceAdmin(admin.ModelAdmin):
    formfield_overrides = {models.JSONField: {"widget": ListWidget}}
```

Each value gets its own `<input type="text">` with a remove button, and there's an "Add item" button at the end.

### Options

Set them as constructor kwargs, or as class attributes on a subclass. Kwargs win over class attributes.

| Option | Default | What it does |
|--------|---------|--------------|
| `mode` | `"text"` | Shortcut for `value_type`. Built in: `"text"` (string) and `"digits"` (number). |
| `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. |
| `input_attrs` | `{}` | Extra attributes set on every value input, e.g. `{"maxLength": 5, "placeholder": "Port"}`. |
| `min_items` | `0` | How many inputs are always on screen. Remove buttons hide when this count is reached. |
| `layout` | `"block"` | `"block"`: one item per row. `"inline"`: items wrap left to right, good for a table cell. |
| `add_label` | `"Add item"` | Accessible name (`title` and `aria-label`) of the add button. |
| `add_text` | `"Add item"` | Visible text of the add button. `""` shows only the icon. |
| `remove_label` | `"Remove item"` | Accessible name of each remove button. |

Standard widget `attrs` work too. A `class` or `style` you pass is added to the hidden textarea next to the widget's own.

`input_attrs` keys are set as DOM properties, not HTML attributes. Use the JS names: `maxLength`, `readOnly`, `inputMode`, not `maxlength`, `readonly`, `inputmode`.

### Reusable subclasses

When the same setup is used in several places, put it in a subclass:

```python
class PortsWidget(ListWidget):
    mode = "digits"
    layout = "inline"
    min_items = 1
    input_attrs = {"maxLength": 5}
    add_label = "Add port"
    add_text = ""
    remove_label = "Remove port"
```

### Values are always strings

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"]`.

Converting and checking values is your form's job. `mode` and `value_type` only tell your code what to expect:

```python
class ServiceForm(forms.ModelForm):
    class Meta:
        model = Service
        fields = "__all__"
        widgets = {"ports": PortsWidget()}

    def clean_ports(self):
        cleaned = []
        for item in self.cleaned_data.get("ports") or []:
            try:
                port = int(item)
            except (TypeError, ValueError):
                raise forms.ValidationError(f"'{item}' is not a valid port number.")
            if not 1 <= port <= 65535:
                raise forms.ValidationError(f"Port {port} must be between 1 and 65535.")
            cleaned.append(port)
        return cleaned
```

The inputs never limit what the user can type. If you want a limit in the browser, add it yourself through `input_attrs`.

### Custom modes

Add a mode with a `MODES` dict on a subclass:

```python
class MyListWidget(ListWidget):
    MODES = {
        **ListWidget.MODES,
        "decimal": {"value_type": "decimal"},
    }


MyListWidget(mode="decimal").value_type  # "decimal"
```

An unknown mode falls back to `value_type="string"`.

### Inline formsets

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.

If you add rows with your own JS, dispatch the same event from the new row, so the widget gets set up:

```js
row.dispatchEvent(new CustomEvent("formset:added", { bubbles: true }));
```

## Styling

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`.

Sizing is up to you. Add your own CSS through the widget's `Media`:

```python
class PortsWidget(ListWidget):
    class Media:
        css = {"all": ("myapp/css/ports.css",)}
```

```css
.jsonsson-list .jsonsson-list-value {
    width: 5ch;
    box-sizing: content-box;
}
```

Django merges a subclass's `Media` with its parent's, so the bundled JS and CSS are still included.

### CSS classes

| Class | Element |
|-------|---------|
| `jsonsson-list` | Outer container |
| `jsonsson-list--block` | Outer container, when `layout="block"` |
| `jsonsson-list-source` | The hidden textarea with the real value |
| `jsonsson-list-items` | Wrapper around items and the add button |
| `jsonsson-list-items--at-min` | Same wrapper, when the item count is at `min_items` |
| `jsonsson-list-item` | One item (input plus remove button) |
| `jsonsson-list-value` | The input of one item |
| `jsonsson-list-remove` | Remove button of one item |
| `jsonsson-list-add` | Add button |
| `jsonsson-list-add--icon-only` | Add button, when `add_text=""` |

### Custom markup

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.

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"`.

The template gets the usual widget context, plus `widget.jsonsson`:

| Key | Value |
|-----|-------|
| `block` | `True` when `layout="block"` |
| `min_items` | `min_items` as an int |
| `input_attrs` | `input_attrs` as a JSON string |
| `add_label`, `add_text`, `remove_label` | The labels |

## Limitations

- 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`.
- Only flat lists of scalar values. Nested lists and objects aren't supported yet.
- Values come back as strings (see above).
- Needs JavaScript. Without it the user sees nothing, since the textarea is hidden.

## Development

Uses [uv](https://docs.astral.sh/uv/).

```bash
make install-dev   # install all dependencies
make test          # run tests
make fmt           # format with black
make lint          # check formatting (CI)
make build         # build sdist and wheel into dist/
```

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.
