Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions frontend/apps/web/src/pages/CreatePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions frontend/apps/web/src/pages/DetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
6 changes: 5 additions & 1 deletion frontend/packages/api/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
37 changes: 37 additions & 0 deletions frontend/packages/form/src/FieldInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<FieldInput
name="window"
field={field({ type: 'range', label: 'Window' })}
value={['2026-01-01', '2026-12-31']}
error={undefined}
onChange={onChange}
/>,
);
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(
<FieldInput
name="window"
field={field({ type: 'range', label: 'Window' })}
value={null}
error={undefined}
onChange={() => {}}
/>,
);
expect(screen.getByRole('textbox', { name: /lower bound/i })).toHaveValue('');
expect(screen.getByRole('textbox', { name: /upper bound/i })).toHaveValue('');
});
});

describe('FieldInput — related "+add" affordance (#383)', () => {
Expand Down
38 changes: 38 additions & 0 deletions frontend/packages/form/src/FieldInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
<div className="flex items-center gap-2">
<input
id={id}
type="text"
value={pair[0]}
placeholder="lower"
aria-label={`${field.label} lower bound`}
onChange={(e) => onChange([e.target.value, pair[1]])}
className={base}
/>
<span aria-hidden className="text-gray-400">
</span>
<input
type="text"
value={pair[1]}
placeholder="upper"
aria-label={`${field.label} upper bound`}
onChange={(e) => onChange([pair[0], e.target.value])}
className={base}
/>
</div>
);
} else {
// Fallback: render value read-only for any type without an editor.
control = (
Expand Down
18 changes: 18 additions & 0 deletions frontend/packages/form/src/RelatedAddModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AddFormResponse | null>(null);
Expand Down
Loading