{}}
+ />,
+ );
+ 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);