diff --git a/cascade_city_by_state.sql b/cascade_city_by_state.sql new file mode 100644 index 0000000..e741aa6 --- /dev/null +++ b/cascade_city_by_state.sql @@ -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; diff --git a/rebuild_city_state_from_branches.sql b/rebuild_city_state_from_branches.sql new file mode 100644 index 0000000..9a34013 --- /dev/null +++ b/rebuild_city_state_from_branches.sql @@ -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; diff --git a/src/api/client.ts b/src/api/client.ts index bd32372..a011025 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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; + }): Promise<{ options: Array<{ label: string; value: string; _raw?: Record }> }> { + 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 { diff --git a/src/api/forms.ts b/src/api/forms.ts index e30d2af..50dd905 100644 --- a/src/api/forms.ts +++ b/src/api/forms.ts @@ -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). */ diff --git a/src/api/schema.ts b/src/api/schema.ts index abb9fbe..2d15b49 100644 --- a/src/api/schema.ts +++ b/src/api/schema.ts @@ -38,7 +38,16 @@ function toFieldDef(f: FormScreenField): FieldDef | null { }; if (type === 'select' || type === 'multiselect') { - def.options = p.options ?? []; + 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; diff --git a/src/api/types.ts b/src/api/types.ts index 7f8e73d..391005b 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 }>; diff --git a/src/components/activities/AssignBody.tsx b/src/components/activities/AssignBody.tsx index fdd31b9..0277c94 100644 --- a/src/components/activities/AssignBody.tsx +++ b/src/components/activities/AssignBody.tsx @@ -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,31 +86,47 @@ 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 ? ( - { - setCity(v); - setRegion(null); // city drives the region list — drop stale picks - setAgent(null); - }} - options={CITY.options} - placeholder={CITY.required ? 'Select…' : '— None —'} - /> - ) : ( - { - setCity(e.target.value); - setRegion(null); - setAgent(null); - }} - /> - ))} + (() => { + // 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 ( + + ); + } + return CITY.options?.length ? ( + + ) : ( + onCity(e.target.value)} + /> + ); + })()} {REGION && ( (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(() => schemaQ.data?.field_rules ?? [], [schemaQ.data]); const [values, setValues] = useState>({}); 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} /> ))} @@ -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; + 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 ( + + ); + } return ( 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; + /** 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(); + 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([]); + + 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 = {}; + 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 ( + + ); +} diff --git a/src/components/form/index.ts b/src/components/form/index.ts index b27d705..5cafb17 100644 --- a/src/components/form/index.ts +++ b/src/components/form/index.ts @@ -1,3 +1,4 @@ export * from './ActivityForm'; +export * from './DatasetSelectField'; export * from './LookupField'; export * from './SchemaGuard';