Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 59 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -790,11 +790,65 @@ HTML admin. The wire shape is identical regardless of locale —
only the human-readable strings change.

**The SPA's own chrome strings** ("Add", "Search", "Save and
continue editing", "Loading…") are still hard-coded English. A
message-catalog refresh + `config.language` wire field is tracked
in [#630](https://github.com/MartinCastroAlvarez/django-admin-react/issues/630). Until that lands, non-English-primary shops
get translated `verbose_name` / `help_text` (via `LocaleMiddleware`)
but English chrome around them.
continue editing", "Loading…") flow through the same locale (#630,
since 1.7.0). Shipped catalogs: English (source-as-key), Spanish,
Portuguese / pt-BR, French. Adding a new language: drop a JSON file
under `frontend/packages/ui/src/i18n/`, import it in
`frontend/packages/ui/src/i18n.ts`, ship.

### Custom widgets (`formfield_overrides` + `registerFieldWidget`)

When your `ModelAdmin` routes a field through a custom widget —
`formfield_overrides = {MyJSONField: {"widget": MyCustomWidget}}`,
or a custom `Form` class declaring widgets directly, or a
third-party widget library — the API surfaces it as
`widget: "custom"` + `widget_class: "<dotted.Python.Path>"`
(`django-admin-rest-api` 1.3.0+). The SPA dispatches the render to
a consumer-registered widget via a small plugin protocol (#625).

Register your widget BEFORE the SPA bundle runs — in your custom
`change_form_template`, a shared base template, or any `<script>`
tag that loads ahead of the SPA's bundle:

```html
<!-- in your Django template -->
<script>
window.darFieldWidgets = window.darFieldWidgets ?? {};
window.darFieldWidgets['mypkg.widgets.MarkdownEditor'] = {
mount(container, props) {
// Read `props.value` for the current value.
// Call `props.onChange(next)` when the operator edits.
// Render whatever — vanilla JS, jQuery, mini-React, …
const textarea = document.createElement('textarea');
textarea.value = props.value ?? '';
textarea.addEventListener('input', (e) => props.onChange(e.target.value));
container.appendChild(textarea);
// (Optional) return a cleanup fn called on SPA unmount.
return () => textarea.remove();
},
};
</script>
```

The `props` object passed to `mount` has:

| Prop | Type | Description |
|---|---|---|
| `value` | `WriteValue` | Current draft value (live — read each access via getter). |
| `onChange` | `(next) => void` | Call to emit a new value; the SPA re-renders. |
| `error` | `string[] \| undefined` | Per-field validation errors from the last save attempt. |
| `widgetClass` | `string` | The dotted class path (handy if a single mount fn handles related widgets). |

When no registration matches the `widget_class` on the wire, the
SPA falls back to a default text input + a small 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.

If you'd rather skip the consumer-side widget for a model and keep
it on the legacy `/admin/`, the
[experience-toggle strip](#experience-toggle-strip-optional) +
`LEGACY_ADMIN_URL_PREFIX` give consumers a one-click hop back.

---

Expand Down
22 changes: 21 additions & 1 deletion frontend/packages/api/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,20 @@ export type FieldType =
* `shuttle_v` come from `filter_horizontal` / `filter_vertical` (#627) —
* the SPA renders Django's two-pane "available / chosen" widget for the
* M2M field, with horizontal or vertical orientation respectively.
* `custom` (api 1.3.0+) marks a field whose bound form widget class lives
* outside `django.*` — `formfield_overrides` / `formfield_for_dbfield` /
* a third-party widget. The SPA dispatches to a consumer-registered
* widget via `registerFieldWidget(widget_class, …)` (#625); falls back
* to the default control + an "open in legacy admin" note when no
* registration matches.
*/
export type WidgetHint = 'radio' | 'raw_id' | 'password' | 'shuttle_h' | 'shuttle_v';
export type WidgetHint =
| 'radio'
| 'raw_id'
| 'password'
| 'shuttle_h'
| 'shuttle_v'
| 'custom';

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

export interface FieldsetDescriptor {
Expand Down
85 changes: 85 additions & 0 deletions frontend/packages/form/src/CustomWidgetMount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// CustomWidgetMount — React adapter for the consumer-supplied
// vanilla mount-fn plugin protocol (#625). Bridges the SPA's
// controlled-form world (value + onChange every render) to the
// imperative DOM world of a `function mount(container, props)`
// contract.
//
// Why this layer:
// - The SPA's React is bundled into the wheel; exposing it on a
// global so consumer React components can use it is fragile
// across rebuilds.
// - A vanilla mount-fn contract keeps the consumer free to use
// any framework (jQuery, Stimulus, mini-React, vanilla DOM).
// - The mount fn is called once on mount with the initial props;
// value changes invoked from inside the widget (via
// `props.onChange`) flow through React's normal re-render path.
// Successive mounts after a re-render don't fire — React's
// useEffect with `[]` deps mounts exactly once.

import { useEffect, useRef } from 'react';

import type { WriteValue } from '@dar/data';
import type { CustomWidgetSpec } from '@dar/ui';

interface CustomWidgetMountProps {
spec: CustomWidgetSpec;
widgetClass: string;
value: WriteValue;
onChange: (next: WriteValue) => void;
error: string[] | undefined;
}

export function CustomWidgetMount({
spec,
widgetClass,
value,
onChange,
error,
}: CustomWidgetMountProps): JSX.Element {
const ref = useRef<HTMLDivElement>(null);
// Latest props the widget needs to see at re-render time. The
// mount fn captured the FIRST onChange; subsequent React renders
// would close over a stale onChange unless we forward via a
// ref. The widget reads through this ref each time it emits.
const latestProps = useRef({ value, onChange, error, widgetClass });
latestProps.current = { value, onChange, error, widgetClass };

useEffect(() => {
if (!ref.current) return undefined;
const container = ref.current;
// Mount with stable proxies — the widget's mount fn captures
// these once; the wrappers read through latestProps.current so
// every emit and every value-read sees the freshest binding.
const cleanup = spec.mount(container, {
get value() {
return latestProps.current.value;
},
onChange: (next) => latestProps.current.onChange(next),
get error() {
return latestProps.current.error;
},
get widgetClass() {
return latestProps.current.widgetClass;
},
});
return () => {
if (typeof cleanup === 'function') cleanup();
// Defensive: if the widget didn't fully tear down its DOM,
// wipe the container so a re-mount starts clean. The mount
// fn is the source of truth; this is a last-resort backstop.
container.innerHTML = '';
};
// Intentional: mount-once contract. Re-mounting on every value
// change would defeat the imperative widget's own state
// management. The latestProps ref pattern (above) forwards
// value / onChange / error to the widget without re-mounting.
}, [spec]);

return (
<div
ref={ref}
data-custom-widget-class={widgetClass}
className="rounded border border-gray-300 p-2"
/>
);
}
46 changes: 45 additions & 1 deletion frontend/packages/form/src/FieldInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import { Plus } from 'lucide-react';

import type { FieldDescriptor, FieldValue, WriteValue } from '@dar/data';
import { FieldValueView } from '@dar/details';
import { Checkbox } from '@dar/ui';
import { Checkbox, lookupFieldWidget, t } from '@dar/ui';

import { AutocompleteInput } from './AutocompleteInput';
import { CustomWidgetMount } from './CustomWidgetMount';
import { RelatedAddModal } from './RelatedAddModal';
import { ShuttleSelect } from './ShuttleSelect';

Expand Down Expand Up @@ -70,6 +71,49 @@ export function FieldInput({ name, field, value, error, onChange }: FieldInputPr
className={base}
/>
);
} else if (field.widget === 'custom' && field.widget_class) {
// Custom widget plugin protocol (#625). The API (1.3.0+) marks
// any field whose bound form widget class lives outside
// ``django.*`` with ``widget: "custom"`` + the widget's dotted
// Python path. Consumers register a vanilla mount fn for that
// class via ``registerFieldWidget(class, {mount})``; the SPA's
// ``CustomWidgetMount`` adapter bridges React → the mount fn's
// imperative DOM world.
//
// If no registration matches, render the default control for the
// field's type with a small inline note so the operator isn't
// stuck — they can still type something into the field; the
// consumer's missing widget is a deployment gap, not a hard
// block. The note links to the legacy admin which has the real
// widget rendered server-side.
const widgetSpec = lookupFieldWidget(field.widget_class);
if (widgetSpec) {
control = (
<CustomWidgetMount
spec={widgetSpec}
widgetClass={field.widget_class}
value={value}
onChange={onChange}
error={error}
/>
);
} else {
control = (
<div className="space-y-1">
<input
id={id}
type="text"
value={value == null ? '' : String(value)}
onChange={(e) => onChange(e.target.value)}
className={base}
/>
<p className="text-xs text-amber-700">
{t('Custom widget')} <code className="font-mono">{field.widget_class}</code>{' '}
{t('is not registered; using the default text input.')}
</p>
</div>
);
}
} else if (field.widget === 'raw_id' && field.type === 'foreignkey') {
// raw_id_fields (#626 / #251). The consumer explicitly OPTED OUT
// of an autocomplete picker — typically because the FK target has
Expand Down
67 changes: 67 additions & 0 deletions frontend/packages/ui/src/custom-widget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Lock the custom widget plugin protocol (#625):
//
// 1. `registerFieldWidget(class, spec)` puts a spec in the
// module-level registry.
// 2. `lookupFieldWidget(class)` returns the registered spec or
// undefined when nothing matches.
// 3. The window.darFieldWidgets global is a fallback path for
// consumers shipping a vanilla <script> registration (no
// build).
// 4. Module-level registry wins over the window global on
// conflict (explicit `registerFieldWidget` is more intentional
// than a global assignment).
import { afterEach, describe, expect, it, vi } from 'vitest';

import {
_resetFieldWidgetRegistryForTests,
lookupFieldWidget,
registerFieldWidget,
} from './custom-widget';

afterEach(() => {
_resetFieldWidgetRegistryForTests();
delete window.darFieldWidgets;
});

describe('registerFieldWidget / lookupFieldWidget', () => {
it('round-trips a registered widget by class name', () => {
const spec = { mount: vi.fn() };
registerFieldWidget('mypkg.MarkdownEditor', spec);
expect(lookupFieldWidget('mypkg.MarkdownEditor')).toBe(spec);
});

it('returns undefined when no registration matches', () => {
expect(lookupFieldWidget('not.registered')).toBeUndefined();
});

it('latest registration wins (re-register overwrites)', () => {
const first = { mount: vi.fn() };
const second = { mount: vi.fn() };
registerFieldWidget('x', first);
registerFieldWidget('x', second);
expect(lookupFieldWidget('x')).toBe(second);
});
});

describe('window.darFieldWidgets fallback (no-build path)', () => {
it('reads from window.darFieldWidgets when the module registry is empty', () => {
const spec = { mount: vi.fn() };
window.darFieldWidgets = { 'global.Widget': spec };
expect(lookupFieldWidget('global.Widget')).toBe(spec);
});

it('module registry wins over the window global on conflict', () => {
const moduleSpec = { mount: vi.fn() };
const globalSpec = { mount: vi.fn() };
window.darFieldWidgets = { 'shared.Widget': globalSpec };
registerFieldWidget('shared.Widget', moduleSpec);
expect(lookupFieldWidget('shared.Widget')).toBe(moduleSpec);
});

it('is robust when window.darFieldWidgets is undefined', () => {
// Simulate a fresh page where no consumer has registered
// anything via either path.
delete window.darFieldWidgets;
expect(lookupFieldWidget('anything')).toBeUndefined();
});
});
Loading
Loading