Skip to content

Commit 743473e

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
feat(spa): editable Range fields — two-input editor wired to the [lower, upper] write shape (#242) (#538)
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: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent af7a6de commit 743473e

6 files changed

Lines changed: 136 additions & 1 deletion

File tree

frontend/apps/web/src/pages/CreatePage.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,26 @@ function CreateForm({ schema, onCreate, onCancel }: CreateFormProps) {
145145
init[name] = Array.isArray(v) ? v.join(',') : null;
146146
continue;
147147
}
148+
if (field.type === 'range') {
149+
// RangeField editor (#242): unwrap the read envelope
150+
// `{subtype, value: {lower, upper, bounds}}` into the
151+
// `[lower, upper]` array `_range_endpoints` accepts (#533). A
152+
// missing default → two empty inputs (unbounded both sides).
153+
if (v && typeof v === 'object' && 'value' in v) {
154+
const inner = (v as { value?: unknown }).value;
155+
if (inner && typeof inner === 'object') {
156+
const lower = (inner as { lower?: unknown }).lower;
157+
const upper = (inner as { upper?: unknown }).upper;
158+
init[name] = [
159+
lower == null ? '' : String(lower),
160+
upper == null ? '' : String(upper),
161+
];
162+
continue;
163+
}
164+
}
165+
init[name] = ['', ''];
166+
continue;
167+
}
148168
// Seed with the model default where the wire carries a scalar;
149169
// FK envelopes / html start empty for a new object.
150170
init[name] = v !== null && typeof v !== 'object' ? v : null;

frontend/apps/web/src/pages/DetailPage.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,24 @@ function initialValueFor(field: DetailResponse['fields'][string]): WriteValue {
358358
// array branch so the scalar list isn't mapped to {id} envelopes.
359359
return Array.isArray(v) ? v.join(',') : null;
360360
}
361+
if (field.type === 'range') {
362+
// RangeField editor (#242): unwrap the read envelope
363+
// `{subtype, value: {lower, upper, bounds}}` into the `[lower, upper]`
364+
// array shape `_range_endpoints` accepts (#533). Checked before the
365+
// generic object branch so the envelope isn't mistaken for an FK.
366+
if (v && typeof v === 'object' && 'value' in v) {
367+
const inner = (v as { value?: unknown }).value;
368+
if (inner && typeof inner === 'object') {
369+
const lower = (inner as { lower?: unknown }).lower;
370+
const upper = (inner as { upper?: unknown }).upper;
371+
return [
372+
lower == null ? '' : String(lower),
373+
upper == null ? '' : String(upper),
374+
];
375+
}
376+
}
377+
return ['', ''];
378+
}
361379
if (Array.isArray(v)) {
362380
// M2M (#240): [{id,label}, ...] → [id, ...] (bare pks for the write).
363381
return v.map((item) =>

frontend/packages/api/src/contract.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,14 @@ export type FieldType =
1919
| 'time'
2020
| 'uuid'
2121
// Structured types the backend emits (#242). `json` / `duration` /
22-
// `array` have editable SPA widgets; others currently render read-only.
22+
// `array` / `range` have editable SPA widgets. `range` values arrive as
23+
// the documented envelope `{subtype, value: {lower, upper, bounds}}`
24+
// (see `docs/api-contract.md` §range types) and write back as a
25+
// `[lower, upper]` pair (#533).
2326
| 'json'
2427
| 'duration'
2528
| 'array'
29+
| 'range'
2630
| 'choice'
2731
| 'foreignkey'
2832
| 'manytomany'

frontend/packages/form/src/FieldInput.test.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,43 @@ describe('FieldInput — structured editors (#242)', () => {
138138
);
139139
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
140140
});
141+
142+
it('renders two text inputs for a range field and sends [lower, upper] on change', () => {
143+
const onChange = vi.fn();
144+
render(
145+
<FieldInput
146+
name="window"
147+
field={field({ type: 'range', label: 'Window' })}
148+
value={['2026-01-01', '2026-12-31']}
149+
error={undefined}
150+
onChange={onChange}
151+
/>,
152+
);
153+
const lower = screen.getByRole('textbox', { name: /lower bound/i });
154+
const upper = screen.getByRole('textbox', { name: /upper bound/i });
155+
expect(lower).toHaveValue('2026-01-01');
156+
expect(upper).toHaveValue('2026-12-31');
157+
// Editing the lower side preserves the upper side in the emitted pair.
158+
fireEvent.change(lower, { target: { value: '2026-02-01' } });
159+
expect(onChange).toHaveBeenLastCalledWith(['2026-02-01', '2026-12-31']);
160+
// …and vice versa: the upper change preserves the lower side.
161+
fireEvent.change(upper, { target: { value: '2027-01-01' } });
162+
expect(onChange).toHaveBeenLastCalledWith(['2026-01-01', '2027-01-01']);
163+
});
164+
165+
it('renders two empty range inputs when value is null (a new object)', () => {
166+
render(
167+
<FieldInput
168+
name="window"
169+
field={field({ type: 'range', label: 'Window' })}
170+
value={null}
171+
error={undefined}
172+
onChange={() => {}}
173+
/>,
174+
);
175+
expect(screen.getByRole('textbox', { name: /lower bound/i })).toHaveValue('');
176+
expect(screen.getByRole('textbox', { name: /upper bound/i })).toHaveValue('');
177+
});
141178
});
142179

143180
describe('FieldInput — related "+add" affordance (#383)', () => {

frontend/packages/form/src/FieldInput.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,44 @@ export function FieldInput({ name, field, value, error, onChange }: FieldInputPr
235235
className={base}
236236
/>
237237
);
238+
} else if (field.type === 'range') {
239+
// RangeField editor (#242, paired with the write-half in #533). Two
240+
// text inputs (lower / upper) wired to the `[lower, upper]` array
241+
// shape `_range_endpoints` accepts. Subtype-aware formatting is the
242+
// user's job: Django's `MultiValueField` sub-fields validate each
243+
// side per its subtype (date / datetime / int / decimal) and surface
244+
// a bad value as a normal field error — same contract as the json /
245+
// duration / array editors. An empty side = unbounded. The form
246+
// seeds the pair from the read envelope `{subtype, value: {lower,
247+
// upper, bounds}}`.
248+
const pair: [string, string] =
249+
Array.isArray(value) && value.length === 2
250+
? [String(value[0] ?? ''), String(value[1] ?? '')]
251+
: ['', ''];
252+
control = (
253+
<div className="flex items-center gap-2">
254+
<input
255+
id={id}
256+
type="text"
257+
value={pair[0]}
258+
placeholder="lower"
259+
aria-label={`${field.label} lower bound`}
260+
onChange={(e) => onChange([e.target.value, pair[1]])}
261+
className={base}
262+
/>
263+
<span aria-hidden className="text-gray-400">
264+
265+
</span>
266+
<input
267+
type="text"
268+
value={pair[1]}
269+
placeholder="upper"
270+
aria-label={`${field.label} upper bound`}
271+
onChange={(e) => onChange([pair[0], e.target.value])}
272+
className={base}
273+
/>
274+
</div>
275+
);
238276
} else {
239277
// Fallback: render value read-only for any type without an editor.
240278
control = (

frontend/packages/form/src/RelatedAddModal.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,27 @@ function seedValue(field: FieldDescriptor): WriteValue {
4444
if (field.type === 'manytomany') return [];
4545
if (field.type === 'json') return v == null ? null : JSON.stringify(v, null, 2);
4646
if (field.type === 'array') return Array.isArray(v) ? v.join(',') : null;
47+
if (field.type === 'range') return rangeToPair(v);
4748
return v != null && typeof v !== 'object' ? (v as WriteValue) : null;
4849
}
4950

51+
// RangeField (#242): unwrap the read envelope
52+
// `{subtype, value: {lower, upper, bounds}}` into the `[lower, upper]`
53+
// array shape the backend `_range_endpoints` accepts (#533). An empty
54+
// side stays empty (= unbounded); a missing envelope → empty pair so a
55+
// new object starts with two empty inputs instead of `null`.
56+
function rangeToPair(v: unknown): WriteValue {
57+
if (v && typeof v === 'object' && 'value' in v) {
58+
const inner = (v as { value?: unknown }).value;
59+
if (inner && typeof inner === 'object') {
60+
const lower = (inner as { lower?: unknown }).lower;
61+
const upper = (inner as { upper?: unknown }).upper;
62+
return [lower == null ? '' : String(lower), upper == null ? '' : String(upper)];
63+
}
64+
}
65+
return ['', ''];
66+
}
67+
5068
export function RelatedAddModal({ to, title, onCreated, onClose }: RelatedAddModalProps) {
5169
const client = useApiClient();
5270
const [schema, setSchema] = useState<AddFormResponse | null>(null);

0 commit comments

Comments
 (0)