feat(spa): custom-widget plugin protocol (#625) + 1.8.0#650
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #625. Cross-repo chain complete:
django-admin-rest-api1.3.0widget: "custom"+widget_classdjango-admin-react1.8.0Plugin protocol
A consumer registers a vanilla mount fn for any
formfield_overrideswidget the SPA doesn't natively render:Vanilla mount fn (not React) so the consumer's widget is framework-agnostic. The SPA wraps it in a thin React effect with a
latestPropsref so value/onChange/error reach the widget without re-mounting on every parent render.Two registration paths
window.darFieldWidgets[<class>]in a regular<script>. Works without an npm publish or consumer-side bundler.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:
Operator can still complete the form. Consumers can keep affected models on legacy admin via
LEGACY_ADMIN_URL_PREFIXuntil 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 forwidget === 'custom'.contract.ts—WidgetHint = … | 'custom';FieldDescriptor.widget_class?: string.Verification
pnpm test— 222 / 222 ✓ (up from 216; +6 new)poetry run pytest -q— 64 / 64 ✓ on Django 4.2.30pnpm -r typecheck✓pnpm lint✓pnpm -w build✓Minor bump
1.7.0→1.8.0. New user-visible capability. Matches the symmetric1.3.0minor ondjango-admin-rest-api.🤖 Generated with Claude Code