-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathRelatedAddModal.tsx
More file actions
179 lines (168 loc) · 6.4 KB
/
Copy pathRelatedAddModal.tsx
File metadata and controls
179 lines (168 loc) · 6.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
// RelatedAddModal — Django's related "+add" popup (#383).
//
// Opened from a foreign-key field's "+" button: loads the *target*
// model's add-form schema, renders its fields with the same FieldInput
// the create page uses, and POSTs via createObject. On success it hands
// the new object back as `{ value, label }` so the FK field can select it
// immediately — matching Django's RelatedFieldWidgetWrapper, which adds
// the new object and selects it without leaving the parent form.
//
// Self-contained (its own schema fetch + state) so it doesn't depend on
// the page-level create flow. Inlines / fieldset grouping are out of
// scope for this popup — it renders the flat visible field set, which is
// what the related add form needs in practice.
import { useEffect, useMemo, useState } from 'react';
import {
ApiError,
createObject,
useApiClient,
type AddFormResponse,
type FieldDescriptor,
type WriteValue,
} from '@dar/data';
import { Button, Modal } from '@dar/ui';
import { FieldInput } from './FieldInput';
export interface RelatedAddModalProps {
/** The FK target model (`field.to`). */
to: { app_label: string; model_name: string };
/** Human label for the modal title (e.g. the FK field's label). */
title?: string;
/** Called with the created object so the opener can select it. */
onCreated: (created: { value: string | number; label: string }) => void;
onClose: () => void;
}
// Seed a write value from a field's default, mirroring the create page:
// scalars pass through; JSON/array seed their string form; objects (FK
// envelopes) and unknown defaults start empty.
function seedValue(field: FieldDescriptor): WriteValue {
const v = field.value;
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);
const [loadError, setLoadError] = useState<string | null>(null);
const [values, setValues] = useState<Record<string, WriteValue>>({});
const [errors, setErrors] = useState<Record<string, string[]>>({});
const [nonFieldError, setNonFieldError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
let live = true;
setSchema(null);
setLoadError(null);
client
.addForm(to.app_label, to.model_name)
.then((res) => {
if (!live) return;
setSchema(res);
const init: Record<string, WriteValue> = {};
for (const [name, field] of Object.entries(res.fields)) {
if (!field.readonly) init[name] = seedValue(field);
}
setValues(init);
})
.catch((err: unknown) => {
if (live) setLoadError(err instanceof Error ? err.message : 'Could not load the form.');
});
return () => {
live = false;
};
}, [client, to.app_label, to.model_name]);
// Visible (non-readonly) fields, in the schema's declared order.
const visibleFields = useMemo(
() => Object.entries(schema?.fields ?? {}).filter(([, f]) => !f.readonly),
[schema],
);
async function submit(): Promise<void> {
if (!schema || saving) return;
setSaving(true);
setErrors({});
setNonFieldError(null);
try {
const res = await createObject({
client,
appLabel: to.app_label,
modelName: to.model_name,
payload: values,
});
onCreated({ value: res.pk, label: res.label });
} catch (err) {
if (err instanceof ApiError && err.envelope?.error?.fields) {
const fields = err.envelope.error.fields;
const { __all__: nonField, ...rest } = fields;
setErrors(rest);
if (nonField?.length) setNonFieldError(nonField.join(' '));
else if (Object.keys(rest).length === 0) {
setNonFieldError(err.envelope.error.message ?? 'Could not save.');
}
} else {
setNonFieldError(err instanceof Error ? err.message : 'Could not save.');
}
} finally {
setSaving(false);
}
}
return (
<Modal title={title ? `Add ${title}` : 'Add'} onClose={onClose}>
{loadError ? (
<p className="text-sm text-red-600">{loadError}</p>
) : !schema ? (
<p className="text-sm text-gray-500">Loading…</p>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
className="space-y-4"
>
{nonFieldError ? (
<p className="rounded border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{nonFieldError}
</p>
) : null}
<div className="space-y-3">
{visibleFields.map(([name, field]) => (
<FieldInput
key={name}
name={name}
field={field}
value={values[name] ?? null}
error={errors[name]}
onChange={(v) => setValues((prev) => ({ ...prev, [name]: v }))}
/>
))}
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</Button>
</div>
</form>
)}
</Modal>
);
}