135 lines
4.1 KiB
TypeScript
135 lines
4.1 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { PickerShell } from '../core/PickerShell';
|
|
import { useZino } from '../../api/provider';
|
|
|
|
export interface LookupValue {
|
|
[col: string]: unknown;
|
|
}
|
|
|
|
export interface LookupFieldProps {
|
|
label?: string;
|
|
required?: boolean;
|
|
templateUid: string;
|
|
/** Columns joined to form the option label. */
|
|
displayCols: string[];
|
|
/** Columns persisted on the value object. */
|
|
storageCols: string[];
|
|
activityId: string;
|
|
fieldId: string;
|
|
instanceId?: number | string;
|
|
value: LookupValue | null;
|
|
onChange: (v: LookupValue | null) => void;
|
|
/** Current form values — sent to the lookup endpoint so the server applies
|
|
* the activity's field_rules filter_options (cascading lookups, e.g. owner
|
|
* agents scoped to the picked region, regions scoped to the picked city).
|
|
* Keys MUST match the rules' `form_field`s; lookup values stay objects so
|
|
* the server can read a rule's `source_value_key` sub-column. */
|
|
formData?: Record<string, unknown>;
|
|
}
|
|
|
|
type Row = Record<string, unknown>;
|
|
|
|
/** Searchable RDBMS-lookup picker (owner agent etc.). Mirrors the renderer's
|
|
* FFLookupField: projects the picked row onto storage + display columns.
|
|
* Renders through the shared PickerShell so it's identical to SelectField. */
|
|
export function LookupField({
|
|
label,
|
|
required,
|
|
templateUid,
|
|
displayCols,
|
|
storageCols,
|
|
activityId,
|
|
fieldId,
|
|
instanceId,
|
|
value,
|
|
onChange,
|
|
formData,
|
|
}: LookupFieldProps) {
|
|
const { client } = useZino();
|
|
// Serialized so the effect refetches when a dependency value changes.
|
|
const formKey = JSON.stringify(formData ?? {});
|
|
const [open, setOpen] = useState(false);
|
|
const [search, setSearch] = useState('');
|
|
const [rows, setRows] = useState<Row[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const labelOf = (src: LookupValue | Row | null) =>
|
|
src
|
|
? displayCols
|
|
.map((c) => src[c])
|
|
.filter((v) => v !== null && v !== undefined && v !== '')
|
|
.join(', ')
|
|
: '';
|
|
|
|
// Collapse rows that project to the same stored value — e.g. many branches
|
|
// sharing one region show "North" once. Lookups whose storage carries a
|
|
// unique id never collapse.
|
|
const dedupe = (list: Row[]) => {
|
|
const cols = storageCols.length ? storageCols : displayCols;
|
|
const seen = new Set<string>();
|
|
return list.filter((row) => {
|
|
const sig = JSON.stringify(cols.map((c) => row[c] ?? null));
|
|
if (seen.has(sig)) return false;
|
|
seen.add(sig);
|
|
return true;
|
|
});
|
|
};
|
|
|
|
// Debounced fetch while the dropdown is open.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
let live = true;
|
|
setLoading(true);
|
|
const t = setTimeout(() => {
|
|
client
|
|
.lookupRecords(templateUid, { activityId, fieldId, instanceId, search, limit: 50, formData })
|
|
.then((r) => {
|
|
// The server scopes rows via filter_options using the form_data we
|
|
// sent (target columns like `city` aren't selected into the rows, so
|
|
// they can only be filtered server-side).
|
|
if (live) setRows(dedupe(r.records ?? []));
|
|
})
|
|
.catch(() => {
|
|
if (live) setRows([]);
|
|
})
|
|
.finally(() => {
|
|
if (live) setLoading(false);
|
|
});
|
|
}, 220);
|
|
return () => {
|
|
live = false;
|
|
clearTimeout(t);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open, search, templateUid, activityId, fieldId, instanceId, client, formKey]);
|
|
|
|
function pick(row: Row) {
|
|
const stored: LookupValue = {};
|
|
storageCols.forEach((c) => (stored[c] = row[c] ?? null));
|
|
displayCols.forEach((c) => {
|
|
if (!(c in stored)) stored[c] = row[c] ?? null;
|
|
});
|
|
onChange(stored);
|
|
}
|
|
|
|
return (
|
|
<PickerShell<Row>
|
|
label={label}
|
|
required={required}
|
|
displayLabel={labelOf(value)}
|
|
items={rows}
|
|
renderItem={(row) => labelOf(row) || 'Row'}
|
|
itemKey={(_row, i) => i}
|
|
onPick={pick}
|
|
onClear={() => onChange(null)}
|
|
search={search}
|
|
onSearch={setSearch}
|
|
loading={loading}
|
|
onOpenChange={(o) => {
|
|
setOpen(o);
|
|
if (!o) setSearch('');
|
|
}}
|
|
/>
|
|
);
|
|
}
|