Skip to content

feat(spa): custom-widget plugin protocol (#625) + 1.8.0#650

Merged
MartinCastroAlvarez merged 1 commit into
mainfrom
feat/custom-widget-plugin-protocol
May 31, 2026
Merged

feat(spa): custom-widget plugin protocol (#625) + 1.8.0#650
MartinCastroAlvarez merged 1 commit into
mainfrom
feat/custom-widget-plugin-protocol

Conversation

@MartinCastroAlvarez
Copy link
Copy Markdown
Owner

Closes #625. Cross-repo chain complete:

Repo Released
django-admin-rest-api 1.3.0 PyPI — emits widget: "custom" + widget_class
django-admin-react 1.8.0 this PR — dispatches to consumer-registered widgets

Plugin protocol

A consumer registers a vanilla mount fn for any formfield_overrides widget the SPA doesn't natively render:

<script>
  window.darFieldWidgets = window.darFieldWidgets ?? {};
  window.darFieldWidgets['mypkg.widgets.MarkdownEditor'] = {
    mount(container, props) {
      // Render whatever — vanilla JS, jQuery, mini-React, etc.
      // `props.value`, `props.onChange`, `props.error`, `props.widgetClass`.
      // Return optional cleanup fn called on SPA unmount.
    },
  };
</script>

Vanilla mount fn (not React) so the consumer's widget is framework-agnostic. The SPA wraps it in a thin React effect with a latestProps ref so value/onChange/error reach the widget without re-mounting on every parent render.

Two registration paths

  • No-build path: assign to window.darFieldWidgets[<class>] in a regular <script>. Works without an npm publish or consumer-side bundler.
  • Module path: import { registerFieldWidget } from '@dar/ui' for consumers shipping their own React module sideloaded into the page. Module-level registrations win on conflict.

Fallback when no registration matches

Default text input + amber note:

Custom widget <class> is not registered; using the default text input.

Operator can still complete the form. Consumers can keep affected models on legacy admin via LEGACY_ADMIN_URL_PREFIX until they wire the widget.

What ships

  • custom-widget.ts — registry + registerFieldWidget / lookupFieldWidget.
  • CustomWidgetMount.tsx — React adapter for the mount fn (latestProps ref pattern).
  • FieldInput.tsx — new branch for widget === 'custom'.
  • contract.tsWidgetHint = … | 'custom'; FieldDescriptor.widget_class?: string.
  • README — full "Custom widgets" section with worked example, props table, fallback semantics.
  • i18n catalogs (es/pt/fr) — translated the two new fallback strings.

Verification

  • pnpm test222 / 222 ✓ (up from 216; +6 new)
  • poetry run pytest -q64 / 64 ✓ on Django 4.2.30
  • pnpm -r typecheck
  • pnpm lint
  • pnpm -w build

Minor bump

1.7.01.8.0. New user-visible capability. Matches the symmetric 1.3.0 minor on django-admin-rest-api.

🤖 Generated with Claude Code

Closes #625. The cross-repo chain is now complete:

| Repo | Released | Notes |
|---|---|---|
| django-admin-rest-api 1.3.0 | PyPI | emits `widget: "custom"` + `widget_class` |
| django-admin-react 1.8.0   | this PR | dispatches to consumer-registered widgets |

## Plugin protocol

A consumer registers a vanilla mount fn for any `formfield_overrides`
widget the SPA doesn't natively render:

```html
<!-- before the SPA bundle runs (custom change_form_template, a
     shared base template, or any <script> ahead of the SPA's bundle): -->
<script>
  window.darFieldWidgets = window.darFieldWidgets ?? {};
  window.darFieldWidgets['mypkg.widgets.MarkdownEditor'] = {
    mount(container, props) {
      // Render whatever — vanilla JS, jQuery, mini-React, etc.
      // `props.value`     — current draft (live via getter)
      // `props.onChange`  — emit a new value
      // `props.error`     — per-field validation errors
      // `props.widgetClass` — the dotted class path
      // return optional cleanup fn called on SPA unmount.
    },
  };
</script>
```

Why vanilla JS rather than React: the SPA's React is bundled +
tree-shaken; exposing it on a global so consumer React modules
could use it is fragile across bundle rebuilds. A vanilla
mount-fn contract keeps the consumer's widget framework-agnostic
(jQuery, Stimulus, mini-React, vanilla DOM all work). The SPA
wraps the mount fn in a thin React effect with the latestProps
ref pattern so value / onChange / error reach the widget without
re-mounting on every render.

## Two registration paths

- **No-build path:** assign to ``window.darFieldWidgets[<class>]``
  in a regular ``<script>``. Works with no npm publish, no
  consumer-side bundler.
- **Module path:** import ``registerFieldWidget`` from ``@dar/ui``
  in a consumer's own SPA build (when they ship their own React
  module sideloaded into the page). Module-level registrations
  win over the window global on conflict.

## Fallback when no registration matches

The SPA renders a default text input + an amber note
(``Custom widget <class> is not registered; using the default text
input.``). The operator can still complete the form; the gap is
explicit and recoverable, not a silent break. Consumers can keep
affected models on the legacy admin via ``LEGACY_ADMIN_URL_PREFIX``
deeplink until they wire the widget.

## What's new

- ``frontend/packages/ui/src/custom-widget.ts`` — the registry
  module + ``registerFieldWidget`` / ``lookupFieldWidget`` exports.
- ``frontend/packages/form/src/CustomWidgetMount.tsx`` — React
  adapter wrapping the consumer's mount fn (latestProps ref so
  re-renders forward value/onChange/error without re-mounting).
- ``FieldInput.tsx`` — new render branch for ``widget === 'custom'``
  with the registered-widget path and the missing-registration
  fallback.
- ``contract.ts`` — ``WidgetHint`` extends ``'custom'``;
  ``FieldDescriptor`` gains ``widget_class?: string``.
- README — new "Custom widgets (formfield_overrides +
  registerFieldWidget)" section with worked example, props table,
  and fallback semantics.
- i18n catalogs (es / pt / fr) — translated the two new fallback
  strings.

## Dep bump

``django-admin-rest-api ^1.2.0`` → ``^1.3.0``. The new
``widget_class`` field + ``"custom"`` widget value land in 1.3.0;
older API versions will never trigger the new SPA branch (they
just emit no hint).

## Verification

- ``pnpm test`` — **222 / 222 ✓** (up from 216; +6 new in
  ``custom-widget.test.ts``)
- ``poetry run pytest -q`` — **64 / 64 ✓** on Django 4.2.30
- ``pnpm -r typecheck`` ✓
- ``pnpm lint`` ✓
- ``pnpm -w build`` ✓

## Minor bump rationale

``1.7.0`` → ``1.8.0``. New user-visible capability (consumer-
supplied custom widgets render in the SPA) per SemVer's "additive
features" guideline. Matches the symmetric ``1.3.0`` minor on
``django-admin-rest-api``.

Closes #625.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@MartinCastroAlvarez MartinCastroAlvarez merged commit b7ec202 into main May 31, 2026
6 checks passed
@MartinCastroAlvarez MartinCastroAlvarez deleted the feat/custom-widget-plugin-protocol branch May 31, 2026 13:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[audit] formfield_overrides custom widgets are ignored — no React extension point for non-stock controls

2 participants