feat: state→city cascade in Add Lead (dataset-backed selects)
DatasetSelectField fetches options live from /dataset-options (filter_options-aware), refetches when its dependency (State) changes, clears stale selection. Wired through schema/types/client/forms for option_source=dataset; AssignBody made dataset-aware so shared city field stays a real select. SQL artifacts: cascade filter rule + rebuild City/State datasets from tbl_branches (every captured city is branch-backed, so Assign region→agent cascade always resolves). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b95733a5f2
commit
e55a26c963
44
cascade_city_by_state.sql
Normal file
44
cascade_city_by_state.sql
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
-- Cascade: City filtered by State on Add Lead activity (app 385)
|
||||||
|
-- Activity 26398, workflow_version 29852 (config row id 405)
|
||||||
|
-- State field = state_region_input (-> States dataset, value_key=state)
|
||||||
|
-- City field = city_input (-> Cities dataset, display=city, value=city_id)
|
||||||
|
--
|
||||||
|
-- Appends a filter_options rule to City: show only Cities rows where
|
||||||
|
-- Cities.state == current value of the State field.
|
||||||
|
-- Idempotent: skips if a rule with id 'rule_city_by_state' already exists.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
UPDATE workflow.tbl_wf_activity_ui_config
|
||||||
|
SET field_rules = COALESCE(field_rules, '[]'::jsonb) || '[
|
||||||
|
{
|
||||||
|
"id": "rule_city_by_state",
|
||||||
|
"action": "filter_options",
|
||||||
|
"active": true,
|
||||||
|
"conditions": null,
|
||||||
|
"action_value": null,
|
||||||
|
"target_field": "city_input",
|
||||||
|
"filter_config": {
|
||||||
|
"logic": "AND",
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"operator": "equals",
|
||||||
|
"form_field": "state_region_input",
|
||||||
|
"target_column": "state",
|
||||||
|
"source_value_key": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]'::jsonb,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE activity_id = 26398
|
||||||
|
AND workflow_version_id = 29852
|
||||||
|
AND NOT COALESCE(field_rules, '[]'::jsonb) @> '[{"id": "rule_city_by_state"}]'::jsonb;
|
||||||
|
|
||||||
|
-- Verify (expect the new rule present)
|
||||||
|
SELECT id, activity_id, jsonb_pretty(field_rules) AS field_rules
|
||||||
|
FROM workflow.tbl_wf_activity_ui_config
|
||||||
|
WHERE activity_id = 26398 AND workflow_version_id = 29852;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
65
rebuild_city_state_from_branches.sql
Normal file
65
rebuild_city_state_from_branches.sql
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
-- Rebuild the State + City datasets from tbl_branches so every captured city is
|
||||||
|
-- always assignable (Create Lead → Assign cascade dependency).
|
||||||
|
--
|
||||||
|
-- App 385. Cities dataset=264, States dataset=265. City field=46815, State field=46814.
|
||||||
|
--
|
||||||
|
-- WHY: Assign Lead's region cascade matches assign_city == tbl_branches.city (city
|
||||||
|
-- NAMES), then owner agent by region. So the city captured at Create Lead must be a
|
||||||
|
-- branch city name. We therefore (a) source the City/State option lists straight from
|
||||||
|
-- tbl_branches, and (b) store the city NAME as the value (value_key=city), not city_id.
|
||||||
|
--
|
||||||
|
-- Datasets are read live from tbl_datasets (no redeploy needed for the data). The field
|
||||||
|
-- property changes live in the deployed workflow config, so REDEPLOY the version after
|
||||||
|
-- running this. Re-runnable: deterministic full rebuild from the current branches.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Cities dataset ← distinct (state_name, city) from branches. keys: state, city.
|
||||||
|
UPDATE tbl_datasets
|
||||||
|
SET keys = '["state","city"]'::jsonb,
|
||||||
|
items = (
|
||||||
|
SELECT COALESCE(
|
||||||
|
jsonb_agg(jsonb_build_object('state', state_name, 'city', city) ORDER BY state_name, city),
|
||||||
|
'[]'::jsonb)
|
||||||
|
FROM (SELECT DISTINCT state_name, city FROM lead_to_policy.tbl_branches) t
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = 264;
|
||||||
|
|
||||||
|
-- States dataset ← distinct state_name from branches. key: state.
|
||||||
|
UPDATE tbl_datasets
|
||||||
|
SET keys = '["state"]'::jsonb,
|
||||||
|
items = (
|
||||||
|
SELECT COALESCE(
|
||||||
|
jsonb_agg(jsonb_build_object('state', state_name) ORDER BY state_name),
|
||||||
|
'[]'::jsonb)
|
||||||
|
FROM (SELECT DISTINCT state_name FROM lead_to_policy.tbl_branches) t
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = 265;
|
||||||
|
|
||||||
|
-- City field (46815): store the city NAME so it matches tbl_branches.city in the
|
||||||
|
-- Assign region cascade (and displays correctly).
|
||||||
|
UPDATE workflow.tbl_wf_fields
|
||||||
|
SET properties = jsonb_set(
|
||||||
|
jsonb_set(properties, '{value_key}', '"city"'::jsonb),
|
||||||
|
'{dataset_keys}', '["state","city"]'::jsonb)
|
||||||
|
WHERE id = 46815;
|
||||||
|
|
||||||
|
-- State field (46814): value + label + keys all on `state`.
|
||||||
|
UPDATE workflow.tbl_wf_fields
|
||||||
|
SET properties = jsonb_set(
|
||||||
|
jsonb_set(
|
||||||
|
jsonb_set(properties, '{value_key}', '"state"'::jsonb),
|
||||||
|
'{display_keys}', '["state"]'::jsonb),
|
||||||
|
'{dataset_keys}', '["state"]'::jsonb)
|
||||||
|
WHERE id = 46814;
|
||||||
|
|
||||||
|
-- Verify
|
||||||
|
SELECT id, name, jsonb_array_length(items) AS rows, keys FROM tbl_datasets WHERE id IN (264,265);
|
||||||
|
SELECT id, field_s_id,
|
||||||
|
properties->>'value_key' AS value_key,
|
||||||
|
properties->'display_keys' AS display_keys
|
||||||
|
FROM workflow.tbl_wf_fields WHERE id IN (46814,46815);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@ -233,6 +233,32 @@ export class ZinoClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Dataset-backed select options (state/city cascade etc.) ---
|
||||||
|
|
||||||
|
/** Fetch options for a dataset-backed select. The server resolves the field's
|
||||||
|
* dataset + filter_options from `activityId`/`fieldId`, applying the activity's
|
||||||
|
* rules against `formData` — so e.g. City options come back already scoped to
|
||||||
|
* the chosen State. Returns `{label, value}` rows (plus the raw dataset row). */
|
||||||
|
datasetOptions(opts: {
|
||||||
|
activityId: string;
|
||||||
|
fieldId: string;
|
||||||
|
search?: string;
|
||||||
|
instanceId?: number | string;
|
||||||
|
limit?: number;
|
||||||
|
formData?: Record<string, unknown>;
|
||||||
|
}): Promise<{ options: Array<{ label: string; value: string; _raw?: Record<string, string> }> }> {
|
||||||
|
return this.request('POST', `/app/${APP_ID}/dataset-options`, {
|
||||||
|
workflow_uuid: WORKFLOW_UUID,
|
||||||
|
activity_id: opts.activityId,
|
||||||
|
field_id: opts.fieldId,
|
||||||
|
instance_id: opts.instanceId || undefined,
|
||||||
|
search: opts.search ?? '',
|
||||||
|
limit: opts.limit ?? 200,
|
||||||
|
offset: 0,
|
||||||
|
form_data: opts.formData ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- File upload + OCR (multipart, field-scoped) ---
|
// --- File upload + OCR (multipart, field-scoped) ---
|
||||||
|
|
||||||
private fieldForm(file: File, ctx: FieldContext): FormData {
|
private fieldForm(file: File, ctx: FieldContext): FormData {
|
||||||
|
|||||||
@ -44,6 +44,12 @@ export interface FieldDef {
|
|||||||
* with (e.g. a hidden preferred_language defaulting to "english"). */
|
* with (e.g. a hidden preferred_language defaulting to "english"). */
|
||||||
prefill?: unknown;
|
prefill?: unknown;
|
||||||
options?: FieldOption[];
|
options?: FieldOption[];
|
||||||
|
/** Dataset-backed select: options are fetched live from /dataset-options
|
||||||
|
* (filter_options-aware) rather than `options`. See DatasetSelectField. */
|
||||||
|
optionSource?: string; // 'dataset' | undefined (inline)
|
||||||
|
datasetUuid?: string;
|
||||||
|
displayKeys?: string[];
|
||||||
|
valueKey?: string;
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
/** Companion/record key to seed from (defaults to id minus the _input suffix). */
|
/** Companion/record key to seed from (defaults to id minus the _input suffix). */
|
||||||
|
|||||||
@ -38,8 +38,17 @@ function toFieldDef(f: FormScreenField): FieldDef | null {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (type === 'select' || type === 'multiselect') {
|
if (type === 'select' || type === 'multiselect') {
|
||||||
|
if (p.option_source === 'dataset' && p.dataset_uuid) {
|
||||||
|
// Dataset-backed: options fetched live (filter_options-aware) at render.
|
||||||
|
def.optionSource = 'dataset';
|
||||||
|
def.datasetUuid = p.dataset_uuid;
|
||||||
|
def.displayKeys = p.display_keys ?? [];
|
||||||
|
def.valueKey = p.value_key ?? '';
|
||||||
|
def.options = [];
|
||||||
|
} else {
|
||||||
def.options = p.options ?? [];
|
def.options = p.options ?? [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (type === 'lookup' && p.lookup_config) {
|
if (type === 'lookup' && p.lookup_config) {
|
||||||
def.lookupTemplate = p.lookup_config.rdbms_template_uuid;
|
def.lookupTemplate = p.lookup_config.rdbms_template_uuid;
|
||||||
def.lookupDisplay = (p.lookup_config.display_columns ?? []).map((c) => c.name);
|
def.lookupDisplay = (p.lookup_config.display_columns ?? []).map((c) => c.name);
|
||||||
|
|||||||
@ -50,6 +50,13 @@ export interface RecordViewParams {
|
|||||||
|
|
||||||
export interface FormScreenFieldProps {
|
export interface FormScreenFieldProps {
|
||||||
options?: Array<{ label: string; value: string }>;
|
options?: Array<{ label: string; value: string }>;
|
||||||
|
/** Dataset-backed select/multiselect: options come live from
|
||||||
|
* /dataset-options (filter_options-aware), not the static `options` above.
|
||||||
|
* `display_keys` are joined for the label; `value_key` is the stored value. */
|
||||||
|
option_source?: string; // 'dataset' | (else inline)
|
||||||
|
dataset_uuid?: string;
|
||||||
|
display_keys?: string[];
|
||||||
|
value_key?: string;
|
||||||
lookup_config?: {
|
lookup_config?: {
|
||||||
rdbms_template_uuid: string;
|
rdbms_template_uuid: string;
|
||||||
display_columns?: Array<{ name: string }>;
|
display_columns?: Array<{ name: string }>;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { CheckCircle2, UserCheck } from 'lucide-react';
|
import { CheckCircle2, UserCheck } from 'lucide-react';
|
||||||
import { AiSuggestionPanel, Button, Card } from '../';
|
import { AiSuggestionPanel, Button, Card } from '../';
|
||||||
import { LookupField, SchemaGuard } from '../form';
|
import { DatasetSelectField, LookupField, SchemaGuard } from '../form';
|
||||||
import type { LookupValue } from '../form';
|
import type { LookupValue } from '../form';
|
||||||
import { SelectField } from '../core/SelectField';
|
import { SelectField } from '../core/SelectField';
|
||||||
import { Input } from '../core/Input';
|
import { Input } from '../core/Input';
|
||||||
@ -86,16 +86,35 @@ export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps)
|
|||||||
seed it from the lead's city and send it so region filters. */}
|
seed it from the lead's city and send it so region filters. */}
|
||||||
{CITY &&
|
{CITY &&
|
||||||
!CITY.hidden &&
|
!CITY.hidden &&
|
||||||
(CITY.options?.length ? (
|
(() => {
|
||||||
|
// city drives the region list — drop stale region/agent picks on change.
|
||||||
|
const onCity = (v: string) => {
|
||||||
|
setCity(v);
|
||||||
|
setRegion(null);
|
||||||
|
setAgent(null);
|
||||||
|
};
|
||||||
|
if (CITY.optionSource === 'dataset' && CITY.datasetUuid) {
|
||||||
|
return (
|
||||||
|
<DatasetSelectField
|
||||||
|
label={CITY.label}
|
||||||
|
required={CITY.required}
|
||||||
|
value={city}
|
||||||
|
onChange={onCity}
|
||||||
|
activityId={DEF.uid}
|
||||||
|
fieldId={CITY.id}
|
||||||
|
instanceId={instanceId}
|
||||||
|
formData={formData}
|
||||||
|
fieldRules={schemaQ.data?.field_rules}
|
||||||
|
placeholder={CITY.required ? 'Select…' : '— None —'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CITY.options?.length ? (
|
||||||
<SelectField
|
<SelectField
|
||||||
label={CITY.label}
|
label={CITY.label}
|
||||||
required={CITY.required}
|
required={CITY.required}
|
||||||
value={city}
|
value={city}
|
||||||
onChange={(v) => {
|
onChange={onCity}
|
||||||
setCity(v);
|
|
||||||
setRegion(null); // city drives the region list — drop stale picks
|
|
||||||
setAgent(null);
|
|
||||||
}}
|
|
||||||
options={CITY.options}
|
options={CITY.options}
|
||||||
placeholder={CITY.required ? 'Select…' : '— None —'}
|
placeholder={CITY.required ? 'Select…' : '— None —'}
|
||||||
/>
|
/>
|
||||||
@ -104,13 +123,10 @@ export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps)
|
|||||||
label={CITY.label}
|
label={CITY.label}
|
||||||
required={CITY.required}
|
required={CITY.required}
|
||||||
value={city}
|
value={city}
|
||||||
onChange={(e) => {
|
onChange={(e) => onCity(e.target.value)}
|
||||||
setCity(e.target.value);
|
|
||||||
setRegion(null);
|
|
||||||
setAgent(null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})()}
|
||||||
{REGION && (
|
{REGION && (
|
||||||
<LookupField
|
<LookupField
|
||||||
label={REGION.label}
|
label={REGION.label}
|
||||||
|
|||||||
@ -6,11 +6,12 @@ import { SelectField } from '../core/SelectField';
|
|||||||
import { MultiSelect } from '../core/MultiSelect';
|
import { MultiSelect } from '../core/MultiSelect';
|
||||||
import { LookupField } from './LookupField';
|
import { LookupField } from './LookupField';
|
||||||
import type { LookupValue } from './LookupField';
|
import type { LookupValue } from './LookupField';
|
||||||
|
import { DatasetSelectField } from './DatasetSelectField';
|
||||||
import { isPermissionError, SchemaGuard } from './SchemaGuard';
|
import { isPermissionError, SchemaGuard } from './SchemaGuard';
|
||||||
import { useQuery, useZino } from '../../api/provider';
|
import { useQuery, useZino } from '../../api/provider';
|
||||||
import { schemaToFields } from '../../api/schema';
|
import { schemaToFields } from '../../api/schema';
|
||||||
import type { ActivityMeta, FieldDef } from '../../api/forms';
|
import type { ActivityMeta, FieldDef } from '../../api/forms';
|
||||||
import type { LeadRecord } from '../../api/types';
|
import type { FieldRule, LeadRecord } from '../../api/types';
|
||||||
import type { ActivityResult } from '../../api/types';
|
import type { ActivityResult } from '../../api/types';
|
||||||
|
|
||||||
export interface ActivityFormProps {
|
export interface ActivityFormProps {
|
||||||
@ -68,6 +69,9 @@ export function ActivityForm({ meta, instanceId, record, onSuccess, successMessa
|
|||||||
() => (schemaQ.data ? schemaToFields(schemaQ.data) : []),
|
() => (schemaQ.data ? schemaToFields(schemaQ.data) : []),
|
||||||
[schemaQ.data],
|
[schemaQ.data],
|
||||||
);
|
);
|
||||||
|
// Cascading rules (filter_options) — passed to dataset-backed selects so a
|
||||||
|
// dependent field (e.g. City) refetches its options when its source (State) changes.
|
||||||
|
const fieldRules = useMemo<FieldRule[]>(() => schemaQ.data?.field_rules ?? [], [schemaQ.data]);
|
||||||
|
|
||||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@ -154,6 +158,7 @@ export function ActivityForm({ meta, instanceId, record, onSuccess, successMessa
|
|||||||
activityId={meta.uid}
|
activityId={meta.uid}
|
||||||
instanceId={instanceId}
|
instanceId={instanceId}
|
||||||
formData={values}
|
formData={values}
|
||||||
|
fieldRules={fieldRules}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -250,6 +255,7 @@ function Field({
|
|||||||
activityId,
|
activityId,
|
||||||
instanceId,
|
instanceId,
|
||||||
formData,
|
formData,
|
||||||
|
fieldRules,
|
||||||
}: {
|
}: {
|
||||||
field: FieldDef;
|
field: FieldDef;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
@ -257,6 +263,7 @@ function Field({
|
|||||||
activityId: string;
|
activityId: string;
|
||||||
instanceId?: number | string;
|
instanceId?: number | string;
|
||||||
formData?: Record<string, unknown>;
|
formData?: Record<string, unknown>;
|
||||||
|
fieldRules?: FieldRule[];
|
||||||
}) {
|
}) {
|
||||||
const full =
|
const full =
|
||||||
field.type === 'longtext' ||
|
field.type === 'longtext' ||
|
||||||
@ -295,6 +302,24 @@ function Field({
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 'select':
|
case 'select':
|
||||||
|
// Dataset-backed select fetches its options live (filter_options-aware);
|
||||||
|
// static selects render from the inline options.
|
||||||
|
if (field.optionSource === 'dataset' && field.datasetUuid) {
|
||||||
|
return (
|
||||||
|
<DatasetSelectField
|
||||||
|
label={field.label}
|
||||||
|
required={field.required}
|
||||||
|
value={String(value ?? '')}
|
||||||
|
onChange={onChange}
|
||||||
|
activityId={activityId}
|
||||||
|
fieldId={field.id}
|
||||||
|
instanceId={instanceId}
|
||||||
|
formData={formData}
|
||||||
|
fieldRules={fieldRules}
|
||||||
|
placeholder={field.required ? 'Select…' : '— None —'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<SelectField
|
<SelectField
|
||||||
label={field.label}
|
label={field.label}
|
||||||
|
|||||||
110
src/components/form/DatasetSelectField.tsx
Normal file
110
src/components/form/DatasetSelectField.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
export * from './ActivityForm';
|
export * from './ActivityForm';
|
||||||
|
export * from './DatasetSelectField';
|
||||||
export * from './LookupField';
|
export * from './LookupField';
|
||||||
export * from './SchemaGuard';
|
export * from './SchemaGuard';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user