111 lines
3.9 KiB
TypeScript
111 lines
3.9 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { SelectField, type SelectFieldOption } from '../core/SelectField';
|
|
import { useZino } from '../../api/provider';
|
|
import type { FieldRule } from '../../api/types';
|
|
|
|
export interface DatasetSelectFieldProps {
|
|
label?: string;
|
|
required?: boolean;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
activityId: string;
|
|
fieldId: string;
|
|
instanceId?: number | string;
|
|
/** Current form values — sent to /dataset-options so the server applies the
|
|
* activity's filter_options (e.g. City options scoped to the chosen State). */
|
|
formData?: Record<string, unknown>;
|
|
/** Activity field_rules — used to find which fields this one depends on, so we
|
|
* only refetch when a dependency value changes (not on every keystroke). */
|
|
fieldRules?: FieldRule[];
|
|
placeholder?: string;
|
|
}
|
|
|
|
/** The form fields whose values drive this field's filter_options. */
|
|
function depFieldsFor(rules: FieldRule[] | undefined, fieldId: string): string[] {
|
|
const out = new Set<string>();
|
|
for (const r of rules ?? []) {
|
|
if (r.action !== 'filter_options' || r.active === false || r.target_field !== fieldId) continue;
|
|
for (const c of r.filter_config?.conditions ?? []) {
|
|
if (c.form_field) out.add(c.form_field);
|
|
}
|
|
}
|
|
return [...out];
|
|
}
|
|
|
|
/** Dataset-backed single-select. Fetches options from /dataset-options (the
|
|
* server applies the activity's filter_options against the dependency values we
|
|
* send), refetching when a dependency changes, and renders the shared
|
|
* SelectField so it looks identical to a static select. When a dependency
|
|
* changes and the current selection is no longer valid, it clears it. */
|
|
export function DatasetSelectField({
|
|
label,
|
|
required,
|
|
value,
|
|
onChange,
|
|
activityId,
|
|
fieldId,
|
|
instanceId,
|
|
formData,
|
|
fieldRules,
|
|
placeholder,
|
|
}: DatasetSelectFieldProps) {
|
|
const { client } = useZino();
|
|
const [options, setOptions] = useState<SelectFieldOption[]>([]);
|
|
|
|
const deps = useMemo(() => depFieldsFor(fieldRules, fieldId), [fieldRules, fieldId]);
|
|
// Stable key over the dependency VALUES — refetch only when these change.
|
|
const depKey = useMemo(
|
|
() => deps.map((f) => `${f}=${JSON.stringify(formData?.[f] ?? null)}`).join('&'),
|
|
[deps, formData],
|
|
);
|
|
// Send only the dependency values — enough to resolve filter_options server-side.
|
|
const depData = useMemo(() => {
|
|
const fd: Record<string, unknown> = {};
|
|
for (const f of deps) fd[f] = formData?.[f];
|
|
return fd;
|
|
}, [deps, formData]);
|
|
|
|
// Refs so stale-selection cleanup reads the latest value/onChange without
|
|
// re-running the fetch effect (which keys only on depKey).
|
|
const onChangeRef = useRef(onChange);
|
|
onChangeRef.current = onChange;
|
|
const valueRef = useRef(value);
|
|
valueRef.current = value;
|
|
const firstDepKey = useRef(depKey);
|
|
|
|
useEffect(() => {
|
|
let live = true;
|
|
client
|
|
.datasetOptions({ activityId, fieldId, instanceId, limit: 200, formData: depData })
|
|
.then((r) => {
|
|
if (!live) return;
|
|
const opts = (r.options ?? []).map((o) => ({ value: String(o.value), label: o.label }));
|
|
setOptions(opts);
|
|
// After a dependency actually CHANGED, drop a now-invalid selection
|
|
// (skip the initial load so a prefilled value isn't cleared).
|
|
if (depKey !== firstDepKey.current) {
|
|
const v = valueRef.current;
|
|
if (v && !opts.some((o) => o.value === String(v))) onChangeRef.current('');
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (live) setOptions([]);
|
|
});
|
|
return () => {
|
|
live = false;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [client, activityId, fieldId, instanceId, depKey]);
|
|
|
|
return (
|
|
<SelectField
|
|
label={label}
|
|
required={required}
|
|
value={String(value ?? '')}
|
|
onChange={onChange}
|
|
options={options}
|
|
placeholder={placeholder}
|
|
/>
|
|
);
|
|
}
|