From eb0b0bc7d4df07e420b0ebd9d85b75038f7d4131 Mon Sep 17 00:00:00 2001 From: Martin Castro Laminrs Date: Thu, 28 May 2026 13:44:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(spa):=20editable=20Range=20fields=20?= =?UTF-8?q?=E2=80=94=20two-input=20editor=20wired=20to=20the=20[lower,=20u?= =?UTF-8?q?pper]=20write=20shape=20(#242)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes **#242**. The backend already serializes ranges as the `{subtype, value: {lower, upper, bounds}}` envelope (#141) and now accepts `[lower, upper]` on the write path (#533) — this PR pairs that with the SPA-side editor, matching the JSON / Duration / Array editors that already shipped. - @dar/api `contract.ts`: add the previously-missing `'range'` to `FieldType` so the closed vocabulary matches what the backend has been emitting (it's documented in `docs/api-contract.md` §field types; the TS mirror was stale). No wire change. - @dar/form `FieldInput`: a `range` branch renders two text inputs (lower / upper) bound to the `[lower, upper]` write shape `_range_endpoints` accepts. An empty side = unbounded. Subtype-aware formatting is the user's job — Django's `MultiValueField` sub-fields validate each side and surface a bad value as a normal field error, same contract as the JSON / Duration / Array editors. - Seeders unwrap the read envelope into the `[lower, upper]` pair in the three sites that prepare form state: `CreatePage`, `DetailPage`'s `initialValueFor`, and `RelatedAddModal`'s `seedValue`. Empty / missing envelopes seed to `['', '']` so new objects start with two blank inputs instead of an unsupported shape. Tests: two new `FieldInput` cases — non-empty value renders both inputs and emits `[newLower, oldUpper]` / `[oldLower, newUpper]` independently; a `null` value renders two empty inputs. Full vitest **142 passed**; typecheck + ESLint (--max-warnings 0) + stylelint + dark-mode guard clean; `pnpm build` ok. Closes #242 Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/apps/web/src/pages/CreatePage.tsx | 20 ++++++++++ frontend/apps/web/src/pages/DetailPage.tsx | 18 +++++++++ frontend/packages/api/src/contract.ts | 6 ++- .../packages/form/src/FieldInput.test.tsx | 37 ++++++++++++++++++ frontend/packages/form/src/FieldInput.tsx | 38 +++++++++++++++++++ .../packages/form/src/RelatedAddModal.tsx | 18 +++++++++ 6 files changed, 136 insertions(+), 1 deletion(-) 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);