Skip to content

Commit 75d324b

Browse files
feat(spa): map every form-spec widget.kind + route strings through i18n (#664, #669)
#664: adaptFormSpec now maps all 23 WidgetKind values onto an exhaustive Record<WidgetKind, WidgetHint|undefined> (a new kind is a compile error). FieldInput renders hidden as a real hidden input, split-datetime as date+time, select-date as a date input, checkbox-multiple/select-multiple as a checkbox bank / <select multiple>, 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) <noreply@anthropic.com>
1 parent 95e3bc8 commit 75d324b

7 files changed

Lines changed: 435 additions & 23 deletions

File tree

frontend/apps/web/src/pages/detail/adaptFormSpec.test.ts

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { describe, expect, it } from 'vitest';
22

3-
import type { DetailResponse, FormSpecField, FormSpecResponse } from '@dar/data';
3+
import type {
4+
DetailResponse,
5+
FormSpecField,
6+
FormSpecResponse,
7+
WidgetHint,
8+
WidgetKind,
9+
} from '@dar/data';
410

511
import { detailFromFormSpec, formSpecFieldToDescriptor } from './adaptFormSpec';
612

@@ -26,12 +32,94 @@ describe('formSpecFieldToDescriptor (#659)', () => {
2632
expect(formSpecFieldToDescriptor(fsField({ widget: { kind: 'shuttle', attrs: {} } })).widget).toBe('shuttle_h');
2733
});
2834

29-
it('leaves widget undefined for kinds the FieldType already implies (select/date/autocomplete/…)', () => {
35+
it('leaves widget undefined for kinds the FieldType already implies (select/date/…)', () => {
3036
expect(formSpecFieldToDescriptor(fsField({ widget: { kind: 'select', attrs: {} } })).widget).toBeUndefined();
3137
expect(formSpecFieldToDescriptor(fsField({ widget: { kind: 'date', attrs: {} } })).widget).toBeUndefined();
32-
expect(
33-
formSpecFieldToDescriptor(fsField({ widget: { kind: 'autocomplete', attrs: {} } })).widget,
34-
).toBeUndefined();
38+
expect(formSpecFieldToDescriptor(fsField({ widget: { kind: 'text', attrs: {} } })).widget).toBeUndefined();
39+
expect(formSpecFieldToDescriptor(fsField({ widget: { kind: 'number', attrs: {} } })).widget).toBeUndefined();
40+
});
41+
42+
it('maps the kinds that need a control FieldType would NOT pick (#664)', () => {
43+
const hintFor = (kind: WidgetKind): WidgetHint | undefined =>
44+
formSpecFieldToDescriptor(fsField({ widget: { kind, attrs: {} } })).widget;
45+
expect(hintFor('hidden')).toBe('hidden');
46+
expect(hintFor('split-datetime')).toBe('split_datetime');
47+
expect(hintFor('select-date')).toBe('select_date');
48+
expect(hintFor('checkbox-multiple')).toBe('checkbox_multiple');
49+
expect(hintFor('select-multiple')).toBe('select_multiple');
50+
expect(hintFor('autocomplete')).toBe('autocomplete');
51+
expect(hintFor('autocomplete-multiple')).toBe('autocomplete_multiple');
52+
expect(hintFor('file')).toBe('file');
53+
});
54+
55+
it('maps EVERY declared WidgetKind to something sensible — no silent no-op (#664)', () => {
56+
// The full closed enum from the contract. If a kind is added to the
57+
// wire without a mapping, this list (and the exhaustive `KIND_TO_HINT`
58+
// record) must be updated — keeping the SPA honest about every kind.
59+
const ALL_KINDS: WidgetKind[] = [
60+
'text',
61+
'textarea',
62+
'number',
63+
'email',
64+
'url',
65+
'password',
66+
'hidden',
67+
'checkbox',
68+
'checkbox-multiple',
69+
'select',
70+
'select-multiple',
71+
'radio',
72+
'date',
73+
'datetime',
74+
'time',
75+
'split-datetime',
76+
'select-date',
77+
'file',
78+
'autocomplete',
79+
'autocomplete-multiple',
80+
'raw-id',
81+
'shuttle',
82+
'custom',
83+
];
84+
// Kinds whose FieldType-derived control is already faithful → no hint.
85+
const NO_HINT = new Set<WidgetKind>([
86+
'text',
87+
'textarea',
88+
'number',
89+
'email',
90+
'url',
91+
'checkbox',
92+
'select',
93+
'date',
94+
'datetime',
95+
'time',
96+
]);
97+
const VALID_HINTS = new Set<WidgetHint>([
98+
'radio',
99+
'raw_id',
100+
'password',
101+
'shuttle_h',
102+
'shuttle_v',
103+
'custom',
104+
'hidden',
105+
'split_datetime',
106+
'select_date',
107+
'checkbox_multiple',
108+
'select_multiple',
109+
'autocomplete',
110+
'autocomplete_multiple',
111+
'file',
112+
'unsupported_widget',
113+
]);
114+
for (const kind of ALL_KINDS) {
115+
const hint = formSpecFieldToDescriptor(fsField({ widget: { kind, attrs: {} } })).widget;
116+
if (NO_HINT.has(kind)) {
117+
expect(hint, `${kind} should defer to FieldType`).toBeUndefined();
118+
} else {
119+
expect(hint, `${kind} must map to a real WidgetHint`).toBeDefined();
120+
expect(VALID_HINTS.has(hint as WidgetHint), `${kind}${hint}`).toBe(true);
121+
}
122+
}
35123
});
36124

37125
it('passes the custom widget_class through so the SPA can dispatch a registered renderer', () => {

frontend/apps/web/src/pages/detail/adaptFormSpec.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,34 @@ import type {
1818
WidgetHint,
1919
} from '@dar/data';
2020

21-
// The closed `widget.kind` enum → the SPA's existing `WidgetHint` controls.
22-
// Kinds with no dedicated hint (select, checkbox, date, file, autocomplete,
23-
// …) return undefined: FieldInput then renders the control implied by
24-
// `FieldType`, which already covers them (a select for `choice`, an
25-
// AutocompleteInput for an FK with a `to` target, etc.).
26-
const KIND_TO_HINT: Partial<Record<WidgetKind, WidgetHint>> = {
21+
// The closed `widget.kind` enum → the SPA's `WidgetHint` controls (#664).
22+
//
23+
// EVERY one of the 23 declared `WidgetKind` values is mapped explicitly so
24+
// no kind silently degrades to a wrong control:
25+
// • `undefined` — the control `FieldType` already implies is faithful
26+
// (e.g. `text` → text input, `select` → <select>, `date` → date input,
27+
// `datetime` → datetime-local, `time` → time input, `checkbox` →
28+
// boolean checkbox, `number` → number input, `email`/`url` → typed
29+
// inputs). Listed here on purpose so adding a kind is a compile error
30+
// until it's classified (the test asserts the record is exhaustive).
31+
// • a real hint — a kind that needs a control `FieldType` would NOT pick
32+
// (hidden, split-datetime, the multi-selects, autocomplete, file, …).
33+
// There is no implicit fallback: an unmapped kind would be a `undefined`
34+
// type error, and a kind we can't render faithfully maps to the explicit,
35+
// operator-visible `unsupported_widget` tracked fallback in FieldInput.
36+
const KIND_TO_HINT: Record<WidgetKind, WidgetHint | undefined> = {
37+
// Kinds whose FieldType-derived control is already faithful.
38+
text: undefined,
39+
textarea: undefined,
40+
number: undefined,
41+
email: undefined,
42+
url: undefined,
43+
checkbox: undefined,
44+
select: undefined,
45+
date: undefined,
46+
datetime: undefined,
47+
time: undefined,
48+
// Security / parity hints that were already wired.
2749
password: 'password',
2850
radio: 'radio',
2951
'raw-id': 'raw_id',
@@ -32,6 +54,15 @@ const KIND_TO_HINT: Partial<Record<WidgetKind, WidgetHint>> = {
3254
// ShuttleSelect supports both and the difference is purely visual).
3355
shuttle: 'shuttle_h',
3456
custom: 'custom',
57+
// Kinds that need a control FieldType alone would render WRONGLY (#664).
58+
hidden: 'hidden',
59+
'split-datetime': 'split_datetime',
60+
'select-date': 'select_date',
61+
'checkbox-multiple': 'checkbox_multiple',
62+
'select-multiple': 'select_multiple',
63+
autocomplete: 'autocomplete',
64+
'autocomplete-multiple': 'autocomplete_multiple',
65+
file: 'file',
3566
};
3667

3768
function maxLengthFrom(field: FormSpecField): number | undefined {

frontend/packages/api/src/contract.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,38 @@ export type FieldType =
4848
* widget via `registerFieldWidget(widget_class, …)` (#625); falls back
4949
* to the default control + an "open in legacy admin" note when no
5050
* registration matches.
51+
*
52+
* The remaining hints map the form-spec `widget.kind` values that need a
53+
* different control than `FieldType` alone implies (#664):
54+
* `hidden` (render a real hidden input, never a visible field);
55+
* `split_datetime` (`SplitDateTimeWidget` — two controls: date + time);
56+
* `select_date` (`SelectDateWidget` — month / day / year selects);
57+
* `checkbox_multiple` (a checkbox bank) / `select_multiple` (a native
58+
* `<select multiple>`) for non-relational multi-value choice fields;
59+
* `autocomplete` / `autocomplete_multiple` (`autocomplete_fields` typeahead);
60+
* `file` (a file input — upload itself is tracked under #241, so this is a
61+
* deliberately limited control with an operator note);
62+
* `unsupported_widget` is the explicit tracked-fallback for any kind with
63+
* no faithful control yet — it renders the default control plus a visible
64+
* operator note (mirroring the `custom`-unregistered branch) so a gap is
65+
* never a silent wrong control.
5166
*/
5267
export type WidgetHint =
5368
| 'radio'
5469
| 'raw_id'
5570
| 'password'
5671
| 'shuttle_h'
5772
| 'shuttle_v'
58-
| 'custom';
73+
| 'custom'
74+
| 'hidden'
75+
| 'split_datetime'
76+
| 'select_date'
77+
| 'checkbox_multiple'
78+
| 'select_multiple'
79+
| 'autocomplete'
80+
| 'autocomplete_multiple'
81+
| 'file'
82+
| 'unsupported_widget';
5983

6084
export interface Permissions {
6185
view: boolean;
@@ -405,6 +429,17 @@ export interface ListResponse {
405429
pk_field: string;
406430
permissions: Permissions;
407431
columns: ColumnDescriptor[];
432+
/**
433+
* `ModelAdmin.list_display_links` (#251 / #666): the column name(s) whose
434+
* cell links to the change page. The backend resolves
435+
* `get_list_display_links` (which defaults to the first column) and emits
436+
* the result; an empty array means `list_display_links = None` — *no*
437+
* column links and the rows are not clickable. Only string column names
438+
* round-trip (callable `list_display` entries are dropped backend-side).
439+
* Optional for back-compat with a pre-1.6.0 backend: when absent the SPA
440+
* falls back to linking the first non-pk column (legacy behaviour).
441+
*/
442+
list_display_links?: string[];
408443
search_fields: string[];
409444
/** `ModelAdmin.search_help_text` — shown under the search box (#445).
410445
* Empty string when unset. */
@@ -720,6 +755,15 @@ export interface FormSpecResponse {
720755
/** Dotted path of the resolved `Form` class — changes when a
721756
* request-aware `get_form` switched form by querystring/user. */
722757
variant: string;
758+
/**
759+
* `ModelAdmin.prepopulated_fields` as `{target: [sources]}` (#245 / #664).
760+
* Emitted by the form-spec endpoint only on the ADD form (rest-api 1.6.0+),
761+
* matching Django's slugify-on-keystroke behaviour for a *new* object. The
762+
* SPA slugifies the target from its sources as the user types, until the
763+
* target is hand-edited. Restricted backend-side to rendered, non-readonly
764+
* targets. Optional/absent on the change form and on a pre-1.6.0 backend.
765+
*/
766+
prepopulated_fields?: Record<string, string[]>;
723767
}
724768

725769
/** Escape hatch: embed the legacy admin change/add page in an iframe. */

0 commit comments

Comments
 (0)