fix: a form field's id is not the workflow field's name

Prefill is computed from the record row, so it is keyed the way the data
  is keyed; the form's fields carry the deploy-assigned suffix. Untranslated,
  the draft landed under a key no control reads and then rode into the submit
  payload, where the workflow refuses the whole activity on an unknown key.

  Re-key prefill and seed onto the form's own field ids, and build the submit
  payload by walking fields rather than posting the whole value bag.

  Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-10 17:31:52 +05:30
parent f90d5b5d53
commit bc0dc4febf

View File

@ -71,13 +71,51 @@ export function ActivityForm({
const fields = schema.data?.fields ?? []; const fields = schema.data?.fields ?? [];
/**
* Re-key a values bag onto the form's OWN field ids.
*
* A form field's id is not the workflow field's name. A mapped activity
* field shares its slug with the workflow-level field it maps to, and
* deploy resolves that collision by suffixing the activity copy so the
* instance data says `cm_approved_amount` and this form's field is
* `cm_approved_amount_2`. Everything we compute from a record row
* (prefill.ts, and any seed) is keyed the way the DATA is keyed, so it has
* to be translated on the way in.
*
* Untranslated it fails twice over, and the first failure hides the second:
* the drafted value lands under a key no control reads, so the form opens
* blank; and it then rides along in the submit payload, where the workflow
* rejects it as an unknown field `validation failed for activity
* hdfc-act-cm-approve: cm_approved_amount(unknown), `.
*
* Match on the exact id first, then on the id with a trailing `_<n>`
* removed. Never hardcode `_2`: the number is deploy-assigned and a slug
* used on two activities takes the next one (`firm_name_2` on Capture,
* `firm_name_3` on Upload Documents).
*/
const toFieldKeys = (bag: Record<string, unknown> | undefined) => {
if (!bag) return {};
const byId = new Map(fields.map((f) => [f.id, f]));
const bySlug = new Map<string, string>();
for (const f of fields) {
const slug = f.id.replace(/_\d+$/, '');
if (slug !== f.id && !byId.has(slug) && !bySlug.has(slug)) bySlug.set(slug, f.id);
}
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(bag)) {
const id = byId.has(k) ? k : bySlug.get(k);
if (id) out[id] = v;
}
return out;
};
// Precedence, weakest first: the platform's own field defaults, then our // Precedence, weakest first: the platform's own field defaults, then our
// computed draft, then the seeded identifiers (which also become read-only). // computed draft, then the seeded identifiers (which also become read-only).
const initial = useMemo( const initial = useMemo(
() => ({ () => ({
...(schema.data?.field_defaults ?? {}), ...(schema.data?.field_defaults ?? {}),
...(prefill ?? {}), ...toFieldKeys(prefill),
...(seed ?? {}), ...toFieldKeys(seed),
}), }),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[schema.data, JSON.stringify(prefill), JSON.stringify(seed)], [schema.data, JSON.stringify(prefill), JSON.stringify(seed)],
@ -143,9 +181,19 @@ export function ActivityForm({
setBusy(true); setBusy(true);
setErr(null); setErr(null);
try { try {
// Submit ONLY what this form actually has a field for. The workflow
// validates every key in the payload and refuses the whole submission
// on an unknown one, so a stray key is not ignored — it is fatal. This
// is the backstop for the re-keying above: if a translation ever misses,
// the field simply arrives empty instead of failing the activity.
const payload: Record<string, unknown> = {};
for (const f of fields) {
if (Object.prototype.hasOwnProperty.call(current, f.id)) payload[f.id] = current[f.id];
}
const res = instanceId const res = instanceId
? await client.performActivity(WORKFLOW, instanceId, activityUid, current) ? await client.performActivity(WORKFLOW, instanceId, activityUid, payload)
: await client.startInstance(WORKFLOW, activityUid, current); : await client.startInstance(WORKFLOW, activityUid, payload);
reset(); reset();
onDone?.({ instance_id: res.instance_id }); onDone?.({ instance_id: res.instance_id });
onClose(); onClose();
@ -183,7 +231,7 @@ export function ActivityForm({
key={f.id} key={f.id}
field={f} field={f}
value={current[f.id]} value={current[f.id]}
readOnly={seed ? Object.prototype.hasOwnProperty.call(seed, f.id) : false} readOnly={Object.prototype.hasOwnProperty.call(toFieldKeys(seed), f.id)}
error={touched && missing.includes(f.id) ? 'Required' : null} error={touched && missing.includes(f.id) ? 'Required' : null}
onChange={(v) => setValues((s) => ({ ...s, [f.id]: v }))} onChange={(v) => setValues((s) => ({ ...s, [f.id]: v }))}
activityUid={activityUid} activityUid={activityUid}