Skip to content

Commit b7ec202

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
feat(spa): custom-widget plugin protocol (#625) + 1.8.0 (#650)
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: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5c293d9 commit b7ec202

12 files changed

Lines changed: 412 additions & 13 deletions

File tree

README.md

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -790,11 +790,65 @@ HTML admin. The wire shape is identical regardless of locale —
790790
only the human-readable strings change.
791791

792792
**The SPA's own chrome strings** ("Add", "Search", "Save and
793-
continue editing", "Loading…") are still hard-coded English. A
794-
message-catalog refresh + `config.language` wire field is tracked
795-
in [#630](https://github.com/MartinCastroAlvarez/django-admin-react/issues/630). Until that lands, non-English-primary shops
796-
get translated `verbose_name` / `help_text` (via `LocaleMiddleware`)
797-
but English chrome around them.
793+
continue editing", "Loading…") flow through the same locale (#630,
794+
since 1.7.0). Shipped catalogs: English (source-as-key), Spanish,
795+
Portuguese / pt-BR, French. Adding a new language: drop a JSON file
796+
under `frontend/packages/ui/src/i18n/`, import it in
797+
`frontend/packages/ui/src/i18n.ts`, ship.
798+
799+
### Custom widgets (`formfield_overrides` + `registerFieldWidget`)
800+
801+
When your `ModelAdmin` routes a field through a custom widget —
802+
`formfield_overrides = {MyJSONField: {"widget": MyCustomWidget}}`,
803+
or a custom `Form` class declaring widgets directly, or a
804+
third-party widget library — the API surfaces it as
805+
`widget: "custom"` + `widget_class: "<dotted.Python.Path>"`
806+
(`django-admin-rest-api` 1.3.0+). The SPA dispatches the render to
807+
a consumer-registered widget via a small plugin protocol (#625).
808+
809+
Register your widget BEFORE the SPA bundle runs — in your custom
810+
`change_form_template`, a shared base template, or any `<script>`
811+
tag that loads ahead of the SPA's bundle:
812+
813+
```html
814+
<!-- in your Django template -->
815+
<script>
816+
window.darFieldWidgets = window.darFieldWidgets ?? {};
817+
window.darFieldWidgets['mypkg.widgets.MarkdownEditor'] = {
818+
mount(container, props) {
819+
// Read `props.value` for the current value.
820+
// Call `props.onChange(next)` when the operator edits.
821+
// Render whatever — vanilla JS, jQuery, mini-React, …
822+
const textarea = document.createElement('textarea');
823+
textarea.value = props.value ?? '';
824+
textarea.addEventListener('input', (e) => props.onChange(e.target.value));
825+
container.appendChild(textarea);
826+
// (Optional) return a cleanup fn called on SPA unmount.
827+
return () => textarea.remove();
828+
},
829+
};
830+
</script>
831+
```
832+
833+
The `props` object passed to `mount` has:
834+
835+
| Prop | Type | Description |
836+
|---|---|---|
837+
| `value` | `WriteValue` | Current draft value (live — read each access via getter). |
838+
| `onChange` | `(next) => void` | Call to emit a new value; the SPA re-renders. |
839+
| `error` | `string[] \| undefined` | Per-field validation errors from the last save attempt. |
840+
| `widgetClass` | `string` | The dotted class path (handy if a single mount fn handles related widgets). |
841+
842+
When no registration matches the `widget_class` on the wire, the
843+
SPA falls back to a default text input + a small amber note
844+
(`Custom widget <class> is not registered; using the default text
845+
input.`). The operator can still complete the form; the gap is
846+
explicit and recoverable, not a silent break.
847+
848+
If you'd rather skip the consumer-side widget for a model and keep
849+
it on the legacy `/admin/`, the
850+
[experience-toggle strip](#experience-toggle-strip-optional) +
851+
`LEGACY_ADMIN_URL_PREFIX` give consumers a one-click hop back.
798852
799853
---
800854

frontend/packages/api/src/contract.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,20 @@ export type FieldType =
4242
* `shuttle_v` come from `filter_horizontal` / `filter_vertical` (#627) —
4343
* the SPA renders Django's two-pane "available / chosen" widget for the
4444
* M2M field, with horizontal or vertical orientation respectively.
45+
* `custom` (api 1.3.0+) marks a field whose bound form widget class lives
46+
* outside `django.*` — `formfield_overrides` / `formfield_for_dbfield` /
47+
* a third-party widget. The SPA dispatches to a consumer-registered
48+
* widget via `registerFieldWidget(widget_class, …)` (#625); falls back
49+
* to the default control + an "open in legacy admin" note when no
50+
* registration matches.
4551
*/
46-
export type WidgetHint = 'radio' | 'raw_id' | 'password' | 'shuttle_h' | 'shuttle_v';
52+
export type WidgetHint =
53+
| 'radio'
54+
| 'raw_id'
55+
| 'password'
56+
| 'shuttle_h'
57+
| 'shuttle_v'
58+
| 'custom';
4759

4860
export interface Permissions {
4961
view: boolean;
@@ -446,6 +458,14 @@ export interface FieldDescriptor {
446458
* has redacted `value` (it ships `null`).
447459
*/
448460
widget?: WidgetHint;
461+
/**
462+
* Dotted Python path of the bound form widget's class (api 1.3.0+),
463+
* present only when `widget` is `"custom"`. The SPA dispatches to a
464+
* consumer-registered widget via `registerFieldWidget(widget_class,
465+
* …)`; falls back to the default control + an "open in legacy admin"
466+
* note when no registration matches (#625).
467+
*/
468+
widget_class?: string;
449469
}
450470

451471
export interface FieldsetDescriptor {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// CustomWidgetMount — React adapter for the consumer-supplied
2+
// vanilla mount-fn plugin protocol (#625). Bridges the SPA's
3+
// controlled-form world (value + onChange every render) to the
4+
// imperative DOM world of a `function mount(container, props)`
5+
// contract.
6+
//
7+
// Why this layer:
8+
// - The SPA's React is bundled into the wheel; exposing it on a
9+
// global so consumer React components can use it is fragile
10+
// across rebuilds.
11+
// - A vanilla mount-fn contract keeps the consumer free to use
12+
// any framework (jQuery, Stimulus, mini-React, vanilla DOM).
13+
// - The mount fn is called once on mount with the initial props;
14+
// value changes invoked from inside the widget (via
15+
// `props.onChange`) flow through React's normal re-render path.
16+
// Successive mounts after a re-render don't fire — React's
17+
// useEffect with `[]` deps mounts exactly once.
18+
19+
import { useEffect, useRef } from 'react';
20+
21+
import type { WriteValue } from '@dar/data';
22+
import type { CustomWidgetSpec } from '@dar/ui';
23+
24+
interface CustomWidgetMountProps {
25+
spec: CustomWidgetSpec;
26+
widgetClass: string;
27+
value: WriteValue;
28+
onChange: (next: WriteValue) => void;
29+
error: string[] | undefined;
30+
}
31+
32+
export function CustomWidgetMount({
33+
spec,
34+
widgetClass,
35+
value,
36+
onChange,
37+
error,
38+
}: CustomWidgetMountProps): JSX.Element {
39+
const ref = useRef<HTMLDivElement>(null);
40+
// Latest props the widget needs to see at re-render time. The
41+
// mount fn captured the FIRST onChange; subsequent React renders
42+
// would close over a stale onChange unless we forward via a
43+
// ref. The widget reads through this ref each time it emits.
44+
const latestProps = useRef({ value, onChange, error, widgetClass });
45+
latestProps.current = { value, onChange, error, widgetClass };
46+
47+
useEffect(() => {
48+
if (!ref.current) return undefined;
49+
const container = ref.current;
50+
// Mount with stable proxies — the widget's mount fn captures
51+
// these once; the wrappers read through latestProps.current so
52+
// every emit and every value-read sees the freshest binding.
53+
const cleanup = spec.mount(container, {
54+
get value() {
55+
return latestProps.current.value;
56+
},
57+
onChange: (next) => latestProps.current.onChange(next),
58+
get error() {
59+
return latestProps.current.error;
60+
},
61+
get widgetClass() {
62+
return latestProps.current.widgetClass;
63+
},
64+
});
65+
return () => {
66+
if (typeof cleanup === 'function') cleanup();
67+
// Defensive: if the widget didn't fully tear down its DOM,
68+
// wipe the container so a re-mount starts clean. The mount
69+
// fn is the source of truth; this is a last-resort backstop.
70+
container.innerHTML = '';
71+
};
72+
// Intentional: mount-once contract. Re-mounting on every value
73+
// change would defeat the imperative widget's own state
74+
// management. The latestProps ref pattern (above) forwards
75+
// value / onChange / error to the widget without re-mounting.
76+
}, [spec]);
77+
78+
return (
79+
<div
80+
ref={ref}
81+
data-custom-widget-class={widgetClass}
82+
className="rounded border border-gray-300 p-2"
83+
/>
84+
);
85+
}

frontend/packages/form/src/FieldInput.tsx

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ import { Plus } from 'lucide-react';
1313

1414
import type { FieldDescriptor, FieldValue, WriteValue } from '@dar/data';
1515
import { FieldValueView } from '@dar/details';
16-
import { Checkbox } from '@dar/ui';
16+
import { Checkbox, lookupFieldWidget, t } from '@dar/ui';
1717

1818
import { AutocompleteInput } from './AutocompleteInput';
19+
import { CustomWidgetMount } from './CustomWidgetMount';
1920
import { RelatedAddModal } from './RelatedAddModal';
2021
import { ShuttleSelect } from './ShuttleSelect';
2122

@@ -70,6 +71,49 @@ export function FieldInput({ name, field, value, error, onChange }: FieldInputPr
7071
className={base}
7172
/>
7273
);
74+
} else if (field.widget === 'custom' && field.widget_class) {
75+
// Custom widget plugin protocol (#625). The API (1.3.0+) marks
76+
// any field whose bound form widget class lives outside
77+
// ``django.*`` with ``widget: "custom"`` + the widget's dotted
78+
// Python path. Consumers register a vanilla mount fn for that
79+
// class via ``registerFieldWidget(class, {mount})``; the SPA's
80+
// ``CustomWidgetMount`` adapter bridges React → the mount fn's
81+
// imperative DOM world.
82+
//
83+
// If no registration matches, render the default control for the
84+
// field's type with a small inline note so the operator isn't
85+
// stuck — they can still type something into the field; the
86+
// consumer's missing widget is a deployment gap, not a hard
87+
// block. The note links to the legacy admin which has the real
88+
// widget rendered server-side.
89+
const widgetSpec = lookupFieldWidget(field.widget_class);
90+
if (widgetSpec) {
91+
control = (
92+
<CustomWidgetMount
93+
spec={widgetSpec}
94+
widgetClass={field.widget_class}
95+
value={value}
96+
onChange={onChange}
97+
error={error}
98+
/>
99+
);
100+
} else {
101+
control = (
102+
<div className="space-y-1">
103+
<input
104+
id={id}
105+
type="text"
106+
value={value == null ? '' : String(value)}
107+
onChange={(e) => onChange(e.target.value)}
108+
className={base}
109+
/>
110+
<p className="text-xs text-amber-700">
111+
{t('Custom widget')} <code className="font-mono">{field.widget_class}</code>{' '}
112+
{t('is not registered; using the default text input.')}
113+
</p>
114+
</div>
115+
);
116+
}
73117
} else if (field.widget === 'raw_id' && field.type === 'foreignkey') {
74118
// raw_id_fields (#626 / #251). The consumer explicitly OPTED OUT
75119
// of an autocomplete picker — typically because the FK target has
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Lock the custom widget plugin protocol (#625):
2+
//
3+
// 1. `registerFieldWidget(class, spec)` puts a spec in the
4+
// module-level registry.
5+
// 2. `lookupFieldWidget(class)` returns the registered spec or
6+
// undefined when nothing matches.
7+
// 3. The window.darFieldWidgets global is a fallback path for
8+
// consumers shipping a vanilla <script> registration (no
9+
// build).
10+
// 4. Module-level registry wins over the window global on
11+
// conflict (explicit `registerFieldWidget` is more intentional
12+
// than a global assignment).
13+
import { afterEach, describe, expect, it, vi } from 'vitest';
14+
15+
import {
16+
_resetFieldWidgetRegistryForTests,
17+
lookupFieldWidget,
18+
registerFieldWidget,
19+
} from './custom-widget';
20+
21+
afterEach(() => {
22+
_resetFieldWidgetRegistryForTests();
23+
delete window.darFieldWidgets;
24+
});
25+
26+
describe('registerFieldWidget / lookupFieldWidget', () => {
27+
it('round-trips a registered widget by class name', () => {
28+
const spec = { mount: vi.fn() };
29+
registerFieldWidget('mypkg.MarkdownEditor', spec);
30+
expect(lookupFieldWidget('mypkg.MarkdownEditor')).toBe(spec);
31+
});
32+
33+
it('returns undefined when no registration matches', () => {
34+
expect(lookupFieldWidget('not.registered')).toBeUndefined();
35+
});
36+
37+
it('latest registration wins (re-register overwrites)', () => {
38+
const first = { mount: vi.fn() };
39+
const second = { mount: vi.fn() };
40+
registerFieldWidget('x', first);
41+
registerFieldWidget('x', second);
42+
expect(lookupFieldWidget('x')).toBe(second);
43+
});
44+
});
45+
46+
describe('window.darFieldWidgets fallback (no-build path)', () => {
47+
it('reads from window.darFieldWidgets when the module registry is empty', () => {
48+
const spec = { mount: vi.fn() };
49+
window.darFieldWidgets = { 'global.Widget': spec };
50+
expect(lookupFieldWidget('global.Widget')).toBe(spec);
51+
});
52+
53+
it('module registry wins over the window global on conflict', () => {
54+
const moduleSpec = { mount: vi.fn() };
55+
const globalSpec = { mount: vi.fn() };
56+
window.darFieldWidgets = { 'shared.Widget': globalSpec };
57+
registerFieldWidget('shared.Widget', moduleSpec);
58+
expect(lookupFieldWidget('shared.Widget')).toBe(moduleSpec);
59+
});
60+
61+
it('is robust when window.darFieldWidgets is undefined', () => {
62+
// Simulate a fresh page where no consumer has registered
63+
// anything via either path.
64+
delete window.darFieldWidgets;
65+
expect(lookupFieldWidget('anything')).toBeUndefined();
66+
});
67+
});

0 commit comments

Comments
 (0)