diff --git a/frontend/apps/web/src/pages/CreatePage.tsx b/frontend/apps/web/src/pages/CreatePage.tsx index 27918e7..d115b17 100644 --- a/frontend/apps/web/src/pages/CreatePage.tsx +++ b/frontend/apps/web/src/pages/CreatePage.tsx @@ -145,6 +145,26 @@ function CreateForm({ schema, onCreate, onCancel }: CreateFormProps) { init[name] = Array.isArray(v) ? v.join(',') : null; continue; } + if (field.type === 'range') { + // RangeField editor (#242): unwrap the read envelope + // `{subtype, value: {lower, upper, bounds}}` into the + // `[lower, upper]` array `_range_endpoints` accepts (#533). A + // missing default → two empty inputs (unbounded both sides). + if (v && typeof v === 'object' && 'value' in v) { + const inner = (v as { value?: unknown }).value; + if (inner && typeof inner === 'object') { + const lower = (inner as { lower?: unknown }).lower; + const upper = (inner as { upper?: unknown }).upper; + init[name] = [ + lower == null ? '' : String(lower), + upper == null ? '' : String(upper), + ]; + continue; + } + } + init[name] = ['', '']; + continue; + } // Seed with the model default where the wire carries a scalar; // FK envelopes / html start empty for a new object. init[name] = v !== null && typeof v !== 'object' ? v : null; diff --git a/frontend/apps/web/src/pages/DetailPage.tsx b/frontend/apps/web/src/pages/DetailPage.tsx index 907c86c..fc24e4c 100644 --- a/frontend/apps/web/src/pages/DetailPage.tsx +++ b/frontend/apps/web/src/pages/DetailPage.tsx @@ -358,6 +358,24 @@ function initialValueFor(field: DetailResponse['fields'][string]): WriteValue { // array branch so the scalar list isn't mapped to {id} envelopes. return Array.isArray(v) ? v.join(',') : null; } + if (field.type === 'range') { + // RangeField editor (#242): unwrap the read envelope + // `{subtype, value: {lower, upper, bounds}}` into the `[lower, upper]` + // array shape `_range_endpoints` accepts (#533). Checked before the + // generic object branch so the envelope isn't mistaken for an FK. + if (v && typeof v === 'object' && 'value' in v) { + const inner = (v as { value?: unknown }).value; + if (inner && typeof inner === 'object') { + const lower = (inner as { lower?: unknown }).lower; + const upper = (inner as { upper?: unknown }).upper; + return [ + lower == null ? '' : String(lower), + upper == null ? '' : String(upper), + ]; + } + } + return ['', '']; + } if (Array.isArray(v)) { // M2M (#240): [{id,label}, ...] → [id, ...] (bare pks for the write). return v.map((item) => diff --git a/frontend/packages/api/src/contract.ts b/frontend/packages/api/src/contract.ts index 67dd736..bb566ab 100644 --- a/frontend/packages/api/src/contract.ts +++ b/frontend/packages/api/src/contract.ts @@ -19,10 +19,14 @@ export type FieldType = | 'time' | 'uuid' // Structured types the backend emits (#242). `json` / `duration` / - // `array` have editable SPA widgets; others currently render read-only. + // `array` / `range` have editable SPA widgets. `range` values arrive as + // the documented envelope `{subtype, value: {lower, upper, bounds}}` + // (see `docs/api-contract.md` §range types) and write back as a + // `[lower, upper]` pair (#533). | 'json' | 'duration' | 'array' + | 'range' | 'choice' | 'foreignkey' | 'manytomany' diff --git a/frontend/packages/form/src/FieldInput.test.tsx b/frontend/packages/form/src/FieldInput.test.tsx index f312cfe..1d2778a 100644 --- a/frontend/packages/form/src/FieldInput.test.tsx +++ b/frontend/packages/form/src/FieldInput.test.tsx @@ -138,6 +138,43 @@ describe('FieldInput — structured editors (#242)', () => { ); expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); }); + + it('renders two text inputs for a range field and sends [lower, upper] on change', () => { + const onChange = vi.fn(); + render( + , + ); + const lower = screen.getByRole('textbox', { name: /lower bound/i }); + const upper = screen.getByRole('textbox', { name: /upper bound/i }); + expect(lower).toHaveValue('2026-01-01'); + expect(upper).toHaveValue('2026-12-31'); + // Editing the lower side preserves the upper side in the emitted pair. + fireEvent.change(lower, { target: { value: '2026-02-01' } }); + expect(onChange).toHaveBeenLastCalledWith(['2026-02-01', '2026-12-31']); + // …and vice versa: the upper change preserves the lower side. + fireEvent.change(upper, { target: { value: '2027-01-01' } }); + expect(onChange).toHaveBeenLastCalledWith(['2026-01-01', '2027-01-01']); + }); + + it('renders two empty range inputs when value is null (a new object)', () => { + render( + {}} + />, + ); + expect(screen.getByRole('textbox', { name: /lower bound/i })).toHaveValue(''); + expect(screen.getByRole('textbox', { name: /upper bound/i })).toHaveValue(''); + }); }); describe('FieldInput — related "+add" affordance (#383)', () => { diff --git a/frontend/packages/form/src/FieldInput.tsx b/frontend/packages/form/src/FieldInput.tsx index ed360c9..f533d39 100644 --- a/frontend/packages/form/src/FieldInput.tsx +++ b/frontend/packages/form/src/FieldInput.tsx @@ -235,6 +235,44 @@ export function FieldInput({ name, field, value, error, onChange }: FieldInputPr className={base} /> ); + } else if (field.type === 'range') { + // RangeField editor (#242, paired with the write-half in #533). Two + // text inputs (lower / upper) wired to the `[lower, upper]` array + // shape `_range_endpoints` accepts. Subtype-aware formatting is the + // user's job: Django's `MultiValueField` sub-fields validate each + // side per its subtype (date / datetime / int / decimal) and surface + // a bad value as a normal field error — same contract as the json / + // duration / array editors. An empty side = unbounded. The form + // seeds the pair from the read envelope `{subtype, value: {lower, + // upper, bounds}}`. + const pair: [string, string] = + Array.isArray(value) && value.length === 2 + ? [String(value[0] ?? ''), String(value[1] ?? '')] + : ['', '']; + control = ( +
+ onChange([e.target.value, pair[1]])} + className={base} + /> + + – + + onChange([pair[0], e.target.value])} + className={base} + /> +
+ ); } else { // Fallback: render value read-only for any type without an editor. control = ( diff --git a/frontend/packages/form/src/RelatedAddModal.tsx b/frontend/packages/form/src/RelatedAddModal.tsx index f585d02..db19085 100644 --- a/frontend/packages/form/src/RelatedAddModal.tsx +++ b/frontend/packages/form/src/RelatedAddModal.tsx @@ -44,9 +44,27 @@ function seedValue(field: FieldDescriptor): WriteValue { if (field.type === 'manytomany') return []; if (field.type === 'json') return v == null ? null : JSON.stringify(v, null, 2); if (field.type === 'array') return Array.isArray(v) ? v.join(',') : null; + if (field.type === 'range') return rangeToPair(v); return v != null && typeof v !== 'object' ? (v as WriteValue) : null; } +// RangeField (#242): unwrap the read envelope +// `{subtype, value: {lower, upper, bounds}}` into the `[lower, upper]` +// array shape the backend `_range_endpoints` accepts (#533). An empty +// side stays empty (= unbounded); a missing envelope → empty pair so a +// new object starts with two empty inputs instead of `null`. +function rangeToPair(v: unknown): WriteValue { + if (v && typeof v === 'object' && 'value' in v) { + const inner = (v as { value?: unknown }).value; + if (inner && typeof inner === 'object') { + const lower = (inner as { lower?: unknown }).lower; + const upper = (inner as { upper?: unknown }).upper; + return [lower == null ? '' : String(lower), upper == null ? '' : String(upper)]; + } + } + return ['', '']; +} + export function RelatedAddModal({ to, title, onCreated, onClose }: RelatedAddModalProps) { const client = useApiClient(); const [schema, setSchema] = useState(null);