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:
Bhanu Prakash Sai Potteri 2026-06-29 17:53:45 +05:30
parent b95733a5f2
commit e55a26c963
10 changed files with 337 additions and 28 deletions

44
cascade_city_by_state.sql Normal file
View 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;

View 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;

View File

@ -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 {

View File

@ -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). */

View File

@ -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);

View File

@ -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 }>;

View File

@ -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}

View File

@ -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}

View 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}
/>
);
}

View File

@ -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';