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) ---
|
||||
|
||||
private fieldForm(file: File, ctx: FieldContext): FormData {
|
||||
|
||||
@ -44,6 +44,12 @@ export interface FieldDef {
|
||||
* with (e.g. a hidden preferred_language defaulting to "english"). */
|
||||
prefill?: unknown;
|
||||
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;
|
||||
placeholder?: string;
|
||||
/** 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 (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 ?? [];
|
||||
}
|
||||
}
|
||||
if (type === 'lookup' && p.lookup_config) {
|
||||
def.lookupTemplate = p.lookup_config.rdbms_template_uuid;
|
||||
def.lookupDisplay = (p.lookup_config.display_columns ?? []).map((c) => c.name);
|
||||
|
||||
@ -50,6 +50,13 @@ export interface RecordViewParams {
|
||||
|
||||
export interface FormScreenFieldProps {
|
||||
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?: {
|
||||
rdbms_template_uuid: string;
|
||||
display_columns?: Array<{ name: string }>;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { CheckCircle2, UserCheck } from 'lucide-react';
|
||||
import { AiSuggestionPanel, Button, Card } from '../';
|
||||
import { LookupField, SchemaGuard } from '../form';
|
||||
import { DatasetSelectField, LookupField, SchemaGuard } from '../form';
|
||||
import type { LookupValue } from '../form';
|
||||
import { SelectField } from '../core/SelectField';
|
||||
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. */}
|
||||
{CITY &&
|
||||
!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
|
||||
label={CITY.label}
|
||||
required={CITY.required}
|
||||
value={city}
|
||||
onChange={(v) => {
|
||||
setCity(v);
|
||||
setRegion(null); // city drives the region list — drop stale picks
|
||||
setAgent(null);
|
||||
}}
|
||||
onChange={onCity}
|
||||
options={CITY.options}
|
||||
placeholder={CITY.required ? 'Select…' : '— None —'}
|
||||
/>
|
||||
@ -104,13 +123,10 @@ export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps)
|
||||
label={CITY.label}
|
||||
required={CITY.required}
|
||||
value={city}
|
||||
onChange={(e) => {
|
||||
setCity(e.target.value);
|
||||
setRegion(null);
|
||||
setAgent(null);
|
||||
}}
|
||||
onChange={(e) => onCity(e.target.value)}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})()}
|
||||
{REGION && (
|
||||
<LookupField
|
||||
label={REGION.label}
|
||||
|
||||
@ -6,11 +6,12 @@ import { SelectField } from '../core/SelectField';
|
||||
import { MultiSelect } from '../core/MultiSelect';
|
||||
import { LookupField } from './LookupField';
|
||||
import type { LookupValue } from './LookupField';
|
||||
import { DatasetSelectField } from './DatasetSelectField';
|
||||
import { isPermissionError, SchemaGuard } from './SchemaGuard';
|
||||
import { useQuery, useZino } from '../../api/provider';
|
||||
import { schemaToFields } from '../../api/schema';
|
||||
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';
|
||||
|
||||
export interface ActivityFormProps {
|
||||
@ -68,6 +69,9 @@ export function ActivityForm({ meta, instanceId, record, onSuccess, successMessa
|
||||
() => (schemaQ.data ? schemaToFields(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 [submitting, setSubmitting] = useState(false);
|
||||
@ -154,6 +158,7 @@ export function ActivityForm({ meta, instanceId, record, onSuccess, successMessa
|
||||
activityId={meta.uid}
|
||||
instanceId={instanceId}
|
||||
formData={values}
|
||||
fieldRules={fieldRules}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -250,6 +255,7 @@ function Field({
|
||||
activityId,
|
||||
instanceId,
|
||||
formData,
|
||||
fieldRules,
|
||||
}: {
|
||||
field: FieldDef;
|
||||
value: unknown;
|
||||
@ -257,6 +263,7 @@ function Field({
|
||||
activityId: string;
|
||||
instanceId?: number | string;
|
||||
formData?: Record<string, unknown>;
|
||||
fieldRules?: FieldRule[];
|
||||
}) {
|
||||
const full =
|
||||
field.type === 'longtext' ||
|
||||
@ -295,6 +302,24 @@ function Field({
|
||||
);
|
||||
|
||||
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 (
|
||||
<SelectField
|
||||
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 './DatasetSelectField';
|
||||
export * from './LookupField';
|
||||
export * from './SchemaGuard';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user