diff --git a/README.md b/README.md index 3e1d893..ffca9aa 100644 --- a/README.md +++ b/README.md @@ -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: ""` +(`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 ` +``` + +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 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. --- diff --git a/frontend/packages/api/src/contract.ts b/frontend/packages/api/src/contract.ts index 8618e1d..73cb9f9 100644 --- a/frontend/packages/api/src/contract.ts +++ b/frontend/packages/api/src/contract.ts @@ -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; @@ -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 { diff --git a/frontend/packages/form/src/CustomWidgetMount.tsx b/frontend/packages/form/src/CustomWidgetMount.tsx new file mode 100644 index 0000000..db55919 --- /dev/null +++ b/frontend/packages/form/src/CustomWidgetMount.tsx @@ -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(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 ( +
+ ); +} diff --git a/frontend/packages/form/src/FieldInput.tsx b/frontend/packages/form/src/FieldInput.tsx index fff1ecb..0845162 100644 --- a/frontend/packages/form/src/FieldInput.tsx +++ b/frontend/packages/form/src/FieldInput.tsx @@ -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'; @@ -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 = ( + + ); + } else { + control = ( +
+ onChange(e.target.value)} + className={base} + /> +

+ {t('Custom widget')} {field.widget_class}{' '} + {t('is not registered; using the default text input.')} +

+
+ ); + } } 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 diff --git a/frontend/packages/ui/src/custom-widget.test.ts b/frontend/packages/ui/src/custom-widget.test.ts new file mode 100644 index 0000000..87bb313 --- /dev/null +++ b/frontend/packages/ui/src/custom-widget.test.ts @@ -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