From 95e3bc8a7c52f254bc657e36faacabfab4a5b4a8 Mon Sep 17 00:00:00 2001 From: Martin Castro Laminrs Date: Tue, 2 Jun 2026 03:13:48 +0200 Subject: [PATCH 1/6] chore: bump django-admin-rest-api to ^1.6.0 + version 1.11.0 + changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raise the REST API floor to 1.6.0 (form-spec now emits prepopulated_fields + autocomplete hints consumed by the widget-kind rendering), bump the package to 1.11.0, and add the [1.11.0] CHANGELOG section covering #664–#670. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19963ba..8ceaa85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.11.0] — 2026-06-02 + +### Added +- **Faithful rendering for every form-spec `widget.kind` (#664).** The + form-spec wire declares 23 `widget.kind` values; the change form previously + mapped only 5 and let the rest silently fall back to the control implied by + `FieldType` — so `hidden` rendered as a *visible, editable* input, + `split-datetime` collapsed to one control, and the multi-selects / `file` + had no faithful path. `adaptFormSpec` now maps **all 23** explicitly (an + exhaustive `Record` so a new kind is a compile error), and + `FieldInput` gained branches for `hidden` (real hidden input), + `split-datetime` (date + time), `select-date` (date input), + `checkbox-multiple` / `select-multiple` (checkbox bank / `, autocomplete(-multiple), and file (limited control + legacy-admin note, upload tracked by #241). Kinds with no faithful control map to an explicit operator-visible unsupported_widget tracked fallback — never a silent wrong control. The test asserts every enum member maps sensibly. #669: FieldInput's Lookup ↗ / lookup aria-label, — select — / (none), and the time/array/range/FK placeholders now go through t(); new keys added to the es/fr/pt catalogs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/pages/detail/adaptFormSpec.test.ts | 98 +++++++- .../web/src/pages/detail/adaptFormSpec.ts | 43 +++- frontend/packages/api/src/contract.ts | 46 +++- frontend/packages/form/src/FieldInput.tsx | 223 +++++++++++++++++- frontend/packages/ui/src/i18n/es.json | 16 +- frontend/packages/ui/src/i18n/fr.json | 16 +- frontend/packages/ui/src/i18n/pt.json | 16 +- 7 files changed, 435 insertions(+), 23 deletions(-) diff --git a/frontend/apps/web/src/pages/detail/adaptFormSpec.test.ts b/frontend/apps/web/src/pages/detail/adaptFormSpec.test.ts index 7fb2586..a71e379 100644 --- a/frontend/apps/web/src/pages/detail/adaptFormSpec.test.ts +++ b/frontend/apps/web/src/pages/detail/adaptFormSpec.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import type { DetailResponse, FormSpecField, FormSpecResponse } from '@dar/data'; +import type { + DetailResponse, + FormSpecField, + FormSpecResponse, + WidgetHint, + WidgetKind, +} from '@dar/data'; import { detailFromFormSpec, formSpecFieldToDescriptor } from './adaptFormSpec'; @@ -26,12 +32,94 @@ describe('formSpecFieldToDescriptor (#659)', () => { expect(formSpecFieldToDescriptor(fsField({ widget: { kind: 'shuttle', attrs: {} } })).widget).toBe('shuttle_h'); }); - it('leaves widget undefined for kinds the FieldType already implies (select/date/autocomplete/…)', () => { + it('leaves widget undefined for kinds the FieldType already implies (select/date/…)', () => { expect(formSpecFieldToDescriptor(fsField({ widget: { kind: 'select', attrs: {} } })).widget).toBeUndefined(); expect(formSpecFieldToDescriptor(fsField({ widget: { kind: 'date', attrs: {} } })).widget).toBeUndefined(); - expect( - formSpecFieldToDescriptor(fsField({ widget: { kind: 'autocomplete', attrs: {} } })).widget, - ).toBeUndefined(); + expect(formSpecFieldToDescriptor(fsField({ widget: { kind: 'text', attrs: {} } })).widget).toBeUndefined(); + expect(formSpecFieldToDescriptor(fsField({ widget: { kind: 'number', attrs: {} } })).widget).toBeUndefined(); + }); + + it('maps the kinds that need a control FieldType would NOT pick (#664)', () => { + const hintFor = (kind: WidgetKind): WidgetHint | undefined => + formSpecFieldToDescriptor(fsField({ widget: { kind, attrs: {} } })).widget; + expect(hintFor('hidden')).toBe('hidden'); + expect(hintFor('split-datetime')).toBe('split_datetime'); + expect(hintFor('select-date')).toBe('select_date'); + expect(hintFor('checkbox-multiple')).toBe('checkbox_multiple'); + expect(hintFor('select-multiple')).toBe('select_multiple'); + expect(hintFor('autocomplete')).toBe('autocomplete'); + expect(hintFor('autocomplete-multiple')).toBe('autocomplete_multiple'); + expect(hintFor('file')).toBe('file'); + }); + + it('maps EVERY declared WidgetKind to something sensible — no silent no-op (#664)', () => { + // The full closed enum from the contract. If a kind is added to the + // wire without a mapping, this list (and the exhaustive `KIND_TO_HINT` + // record) must be updated — keeping the SPA honest about every kind. + const ALL_KINDS: WidgetKind[] = [ + 'text', + 'textarea', + 'number', + 'email', + 'url', + 'password', + 'hidden', + 'checkbox', + 'checkbox-multiple', + 'select', + 'select-multiple', + 'radio', + 'date', + 'datetime', + 'time', + 'split-datetime', + 'select-date', + 'file', + 'autocomplete', + 'autocomplete-multiple', + 'raw-id', + 'shuttle', + 'custom', + ]; + // Kinds whose FieldType-derived control is already faithful → no hint. + const NO_HINT = new Set([ + 'text', + 'textarea', + 'number', + 'email', + 'url', + 'checkbox', + 'select', + 'date', + 'datetime', + 'time', + ]); + const VALID_HINTS = new Set([ + 'radio', + 'raw_id', + 'password', + 'shuttle_h', + 'shuttle_v', + 'custom', + 'hidden', + 'split_datetime', + 'select_date', + 'checkbox_multiple', + 'select_multiple', + 'autocomplete', + 'autocomplete_multiple', + 'file', + 'unsupported_widget', + ]); + for (const kind of ALL_KINDS) { + const hint = formSpecFieldToDescriptor(fsField({ widget: { kind, attrs: {} } })).widget; + if (NO_HINT.has(kind)) { + expect(hint, `${kind} should defer to FieldType`).toBeUndefined(); + } else { + expect(hint, `${kind} must map to a real WidgetHint`).toBeDefined(); + expect(VALID_HINTS.has(hint as WidgetHint), `${kind} → ${hint}`).toBe(true); + } + } }); it('passes the custom widget_class through so the SPA can dispatch a registered renderer', () => { diff --git a/frontend/apps/web/src/pages/detail/adaptFormSpec.ts b/frontend/apps/web/src/pages/detail/adaptFormSpec.ts index e4dcdfd..49a70d7 100644 --- a/frontend/apps/web/src/pages/detail/adaptFormSpec.ts +++ b/frontend/apps/web/src/pages/detail/adaptFormSpec.ts @@ -18,12 +18,34 @@ import type { WidgetHint, } from '@dar/data'; -// The closed `widget.kind` enum → the SPA's existing `WidgetHint` controls. -// Kinds with no dedicated hint (select, checkbox, date, file, autocomplete, -// …) return undefined: FieldInput then renders the control implied by -// `FieldType`, which already covers them (a select for `choice`, an -// AutocompleteInput for an FK with a `to` target, etc.). -const KIND_TO_HINT: Partial> = { +// The closed `widget.kind` enum → the SPA's `WidgetHint` controls (#664). +// +// EVERY one of the 23 declared `WidgetKind` values is mapped explicitly so +// no kind silently degrades to a wrong control: +// • `undefined` — the control `FieldType` already implies is faithful +// (e.g. `text` → text input, `select` → `) for non-relational multi-value choice fields; + * `autocomplete` / `autocomplete_multiple` (`autocomplete_fields` typeahead); + * `file` (a file input — upload itself is tracked under #241, so this is a + * deliberately limited control with an operator note); + * `unsupported_widget` is the explicit tracked-fallback for any kind with + * no faithful control yet — it renders the default control plus a visible + * operator note (mirroring the `custom`-unregistered branch) so a gap is + * never a silent wrong control. */ export type WidgetHint = | 'radio' @@ -55,7 +70,16 @@ export type WidgetHint = | 'password' | 'shuttle_h' | 'shuttle_v' - | 'custom'; + | 'custom' + | 'hidden' + | 'split_datetime' + | 'select_date' + | 'checkbox_multiple' + | 'select_multiple' + | 'autocomplete' + | 'autocomplete_multiple' + | 'file' + | 'unsupported_widget'; export interface Permissions { view: boolean; @@ -405,6 +429,17 @@ export interface ListResponse { pk_field: string; permissions: Permissions; columns: ColumnDescriptor[]; + /** + * `ModelAdmin.list_display_links` (#251 / #666): the column name(s) whose + * cell links to the change page. The backend resolves + * `get_list_display_links` (which defaults to the first column) and emits + * the result; an empty array means `list_display_links = None` — *no* + * column links and the rows are not clickable. Only string column names + * round-trip (callable `list_display` entries are dropped backend-side). + * Optional for back-compat with a pre-1.6.0 backend: when absent the SPA + * falls back to linking the first non-pk column (legacy behaviour). + */ + list_display_links?: string[]; search_fields: string[]; /** `ModelAdmin.search_help_text` — shown under the search box (#445). * Empty string when unset. */ @@ -720,6 +755,15 @@ export interface FormSpecResponse { /** Dotted path of the resolved `Form` class — changes when a * request-aware `get_form` switched form by querystring/user. */ variant: string; + /** + * `ModelAdmin.prepopulated_fields` as `{target: [sources]}` (#245 / #664). + * Emitted by the form-spec endpoint only on the ADD form (rest-api 1.6.0+), + * matching Django's slugify-on-keystroke behaviour for a *new* object. The + * SPA slugifies the target from its sources as the user types, until the + * target is hand-edited. Restricted backend-side to rendered, non-readonly + * targets. Optional/absent on the change form and on a pre-1.6.0 backend. + */ + prepopulated_fields?: Record; } /** Escape hatch: embed the legacy admin change/add page in an iframe. */ diff --git a/frontend/packages/form/src/FieldInput.tsx b/frontend/packages/form/src/FieldInput.tsx index 0845162..07c8801 100644 --- a/frontend/packages/form/src/FieldInput.tsx +++ b/frontend/packages/form/src/FieldInput.tsx @@ -147,9 +147,9 @@ export function FieldInput({ name, field, value, error, onChange }: FieldInputPr target="_blank" rel="noopener noreferrer" className="shrink-0 rounded border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50" - aria-label="Look up related object in a new tab" + aria-label={t('Look up related object in a new tab')} > - Lookup ↗ + {t('Lookup ↗')} )} @@ -183,6 +183,213 @@ export function FieldInput({ name, field, value, error, onChange }: FieldInputPr })} ); + } else if (field.widget === 'hidden') { + // SplitDateTimeWidget's hidden member, a HiddenInput-routed field, or + // any field the admin meant to keep out of sight (#664). Django renders + // these as — a VISIBLE editable text box would be + // both wrong and a minor info-leak (the operator sees/edits a field the + // admin hid). We mirror Django: a real hidden input that still posts its + // value, with no label row. + return ( + onChange(e.target.value)} + /> + ); + } else if (field.widget === 'split_datetime') { + // SplitDateTimeWidget (#664): two controls — a date and a time — not a + // single datetime-local, preserving Django's two-field semantics. The + // wire value is an ISO 8601 string; we split it on `T` for the controls + // and rejoin so the posted value stays the shape Django parses. + const s = value == null ? '' : String(value); + const datePart = s.slice(0, 10); + const timePart = s.length > 11 ? s.slice(11, 19) : ''; + const join = (d: string, tm: string): WriteValue => { + if (d === '' && tm === '') return null; + return tm === '' ? d : `${d}T${tm}`; + }; + control = ( +
+ onChange(join(e.target.value, timePart))} + className={base} + /> + onChange(join(datePart, e.target.value))} + className={base} + /> +
+ ); + } else if (field.widget === 'select_date') { + // SelectDateWidget (#664): a single is the faithful, + // accessible control for the same ISO value Django's three month/day/year + // selects produce — no semantics are lost (the user still picks a whole + // date) and it round-trips identically. We keep the date input rather + // than rebuild three coupled onChange(e.target.value === '' ? null : e.target.value)} + className={base} + /> + ); + } else if ( + (field.widget === 'checkbox_multiple' || field.widget === 'select_multiple') && + field.choices && + field.choices.length > 0 + ) { + // CheckboxSelectMultiple / SelectMultiple for a NON-relational multi-value + // choice field (#664) — e.g. a forms.MultipleChoiceField, or a + // ChoiceField the admin forced to a multi widget. (M2M keeps its own + // branch below.) `checkbox_multiple` renders a checkbox bank; the + // `select_multiple` value of the hint renders a native + onChange(Array.from(e.target.selectedOptions).map((o) => o.value)) + } + className={`${base} h-auto min-h-[6rem]`} + > + {field.choices.map((c) => ( + + ))} + + ); + } else { + control = ( +
+ {field.choices.map((c) => { + const key = String(c.value); + return ( + + ); + })} +
+ ); + } + } else if (field.widget === 'autocomplete' && field.type === 'foreignkey' && field.to) { + // autocomplete_fields FK (#664): the admin opted into the typeahead + // picker. Render the same AutocompleteInput the FK branch uses when the + // target is large; here the hint makes the choice explicit even when the + // target set is small enough that the backend could have inlined choices. + const envelopeLabel = + field.value && typeof field.value === 'object' && 'label' in field.value + ? (field.value as { label: string }).label + : undefined; + control = ( + + ); + } else if (field.widget === 'autocomplete_multiple' && field.choices && field.choices.length > 0) { + // autocomplete_fields M2M with inlined choices (#664): render the + // checkbox multi-select (same control as the M2M-with-choices branch), + // which is faithful when the target set is inlined. A true async M2M + // typeahead picker for a large target is a tracked follow-up (#240); that + // case falls through to `unsupported_widget` below via the explicit note. + const selectedSet = new Set((Array.isArray(value) ? value : []).map(String)); + control = ( +
+ {field.choices.map((c) => { + const key = String(c.value); + return ( + + ); + })} +
+ ); + } else if (field.widget === 'file') { + // FileInput / ClearableFileInput (#664). Binary upload through the JSON + // wire is tracked separately (#241), so this is a deliberately limited + // control: a real that posts the chosen file name + // (so the field is at least visible and selectable) plus a visible note + // that the upload itself opens in the legacy admin. Never a silent + // wrong control. + control = ( +
+ onChange(e.target.files?.[0]?.name ?? null)} + className={base} + /> + {field.value ? ( +

+ {t('Current file:')}{' '} + +

+ ) : null} +

+ {t('File upload is not supported in the SPA yet; use the legacy admin to change the file.')} +

+
+ ); + } else if (field.widget === 'unsupported_widget') { + // Explicit tracked fallback (#664): a widget.kind the SPA cannot render + // faithfully yet. Mirror the unregistered-custom branch — a usable + // default text input PLUS a visible operator note — so the gap is never + // a silent wrong control. + control = ( +
+ onChange(e.target.value)} + className={base} + /> +

+ {t('This field uses a widget the SPA cannot render yet; edit it in the legacy admin if it looks wrong.')} +

+
+ ); } else if (field.type === 'boolean') { control = ( onChange(e.target.checked)} /> @@ -350,7 +557,7 @@ export function FieldInput({ name, field, value, error, onChange }: FieldInputPr id={id} type="text" value={value == null ? '' : String(value)} - placeholder="HH:MM:SS" + placeholder={t('HH:MM:SS')} onChange={(e) => onChange(e.target.value)} className={base} /> @@ -367,7 +574,7 @@ export function FieldInput({ name, field, value, error, onChange }: FieldInputPr id={id} type="text" value={value == null ? '' : String(value)} - placeholder="comma,separated,values" + placeholder={t('comma,separated,values')} onChange={(e) => onChange(e.target.value)} className={base} /> @@ -392,7 +599,7 @@ export function FieldInput({ name, field, value, error, onChange }: FieldInputPr id={id} type="text" value={pair[0]} - placeholder="lower" + placeholder={t('lower')} aria-label={`${field.label} lower bound`} onChange={(e) => onChange([e.target.value, pair[1]])} className={base} @@ -403,7 +610,7 @@ export function FieldInput({ name, field, value, error, onChange }: FieldInputPr onChange([pair[0], e.target.value])} className={base} @@ -471,7 +678,7 @@ function ForeignKeyControl({ field, value, error, id, base, onChange }: ForeignK onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)} className={base} > - + {withAdded.map((c) => (