fix(jotform): stop duplicate question labels overwriting derived answers

Question labels are not unique — a form can carry two questions both
labelled "Email" — so keying the derived `values` map on the label alone
dropped all but the last and handed downstream workflows a confidently
wrong answer.

Every occurrence of a repeated label is now suffixed with its question ID,
rather than only the later ones, so the result does not depend on answer
order and a newly duplicated label reads as absent instead of as an
arbitrary winner. The id-keyed `answers` record was already complete and
is unchanged.
This commit is contained in:
Waleed Latif
2026-08-16 22:58:31 -07:00
parent 40e19236f1
commit f9026cfdff
9 changed files with 97 additions and 22 deletions
@@ -431,7 +431,7 @@ List the submissions received by one form, with each answer available both by qu
| ↳ `new` | string | 1 when the submission is unread |
| ↳ `workflowStatus` | string | Approval state, present only when the form feeds a workflow |
| ↳ `answers` | json | Answers keyed by question ID. Each holds text \(the question label\), type, answer, and prettyFormat when Jotform renders one. |
| ↳ `values` | json | The same answers re-keyed by question label, each rendered as a single string |
| ↳ `values` | json | The same answers re-keyed by question label, each rendered as a single string. A label shared by more than one question is suffixed with its question ID on every occurrence, so no answer is lost. |
| `pagination` | object | Result window reported by the API |
| ↳ `offset` | number | Index of the first returned submission |
| ↳ `limit` | number | Page size applied |
@@ -467,7 +467,7 @@ List submissions across every form on the account, optionally narrowed to specif
| ↳ `new` | string | 1 when the submission is unread |
| ↳ `workflowStatus` | string | Approval state, present only when the form feeds a workflow |
| ↳ `answers` | json | Answers keyed by question ID. Each holds text \(the question label\), type, answer, and prettyFormat when Jotform renders one. |
| ↳ `values` | json | The same answers re-keyed by question label, each rendered as a single string |
| ↳ `values` | json | The same answers re-keyed by question label, each rendered as a single string. A label shared by more than one question is suffixed with its question ID on every occurrence, so no answer is lost. |
| `pagination` | object | Result window reported by the API |
| ↳ `offset` | number | Index of the first returned submission |
| ↳ `limit` | number | Page size applied |
@@ -499,7 +499,7 @@ Get a single Jotform submission, with its answers available both by question ID
| ↳ `new` | string | 1 when the submission is unread |
| ↳ `workflowStatus` | string | Approval state, present only when the form feeds a workflow |
| ↳ `answers` | json | Answers keyed by question ID. Each holds text \(the question label\), type, answer, and prettyFormat when Jotform renders one. |
| ↳ `values` | json | The same answers re-keyed by question label, each rendered as a single string |
| ↳ `values` | json | The same answers re-keyed by question label, each rendered as a single string. A label shared by more than one question is suffixed with its question ID on every occurrence, so no answer is lost. |
### Jotform Create Submission
+3 -2
View File
@@ -864,11 +864,12 @@ export const JotformBlock: BlockConfig = {
submissions: {
type: 'json',
description:
'Submissions, each with answers keyed by question ID and values keyed by question label (list_form_submissions, list_submissions)',
'Submissions, each with answers keyed by question ID and values keyed by question label, repeated labels suffixed with their question ID (list_form_submissions, list_submissions)',
},
submission: {
type: 'json',
description: 'A single submission with its answers and values (get_submission)',
description:
'A single submission with its answers keyed by question ID and values keyed by question label (get_submission)',
},
submissionId: {
type: 'string',
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -83,7 +83,7 @@ export const getSubmissionTool: ToolConfig<JotformGetSubmissionParams, JotformSu
values: {
type: 'json',
description:
'The same answers re-keyed by question label, each rendered as a single string',
'The same answers re-keyed by question label, each rendered as a single string. A label shared by more than one question is suffixed with its question ID on every occurrence, so no answer is lost.',
},
},
},
+50
View File
@@ -444,3 +444,53 @@ describe('error envelope robustness', () => {
).rejects.toThrow(/^Jotform Get Form error \(502\): .{1,320}$/s)
})
})
describe('duplicate question labels', () => {
/**
* Question labels are not unique a form can carry two questions both labelled
* "Email". Keying `values` on the label alone silently dropped all but the last,
* handing downstream workflows a confidently wrong answer.
*/
it('disambiguates every occurrence of a repeated label with its question ID', () => {
const submission = normalizeSubmission({
id: '1',
answers: {
'3': { text: 'Email', type: 'control_email', answer: 'first@example.com' },
'7': { text: 'Email', type: 'control_email', answer: 'second@example.com' },
'9': { text: 'Message', type: 'control_textarea', answer: 'Hello' },
},
})
expect(submission.values).toEqual({
'Email (3)': 'first@example.com',
'Email (7)': 'second@example.com',
Message: 'Hello',
})
/* Never an arbitrary winner under the bare key. */
expect(submission.values.Email).toBeUndefined()
})
it('keeps a unique label bare', () => {
const submission = normalizeSubmission({
id: '1',
answers: { '3': { text: 'Email', type: 'control_email', answer: 'only@example.com' } },
})
expect(submission.values).toEqual({ Email: 'only@example.com' })
})
/** The id-keyed record stays complete regardless of how labels collide. */
it('never loses an answer from the id-keyed record', () => {
const submission = normalizeSubmission({
id: '1',
answers: {
'3': { text: 'Email', type: 'control_email', answer: 'a@example.com' },
'7': { text: 'Email', type: 'control_email', answer: 'b@example.com' },
},
})
expect(Object.keys(submission.answers)).toEqual(['3', '7'])
expect(submission.answers['3'].answer).toBe('a@example.com')
expect(submission.answers['7'].answer).toBe('b@example.com')
})
})
@@ -127,7 +127,7 @@ export const listFormSubmissionsTool: ToolConfig<
values: {
type: 'json',
description:
'The same answers re-keyed by question label, each rendered as a single string',
'The same answers re-keyed by question label, each rendered as a single string. A label shared by more than one question is suffixed with its question ID on every occurrence, so no answer is lost.',
},
},
},
+1 -1
View File
@@ -111,7 +111,7 @@ export const listSubmissionsTool: ToolConfig<JotformListParams, JotformListSubmi
values: {
type: 'json',
description:
'The same answers re-keyed by question label, each rendered as a single string',
'The same answers re-keyed by question label, each rendered as a single string. A label shared by more than one question is suffixed with its question ID on every occurrence, so no answer is lost.',
},
},
},
+32 -12
View File
@@ -72,27 +72,47 @@ function normalizeAnswer(raw: Record<string, unknown>): JotformSubmissionAnswer
}
}
/** Renders one answer as the single string `values` holds. */
function renderAnswer(answer: JotformSubmissionAnswer): string | null {
if (answer.prettyFormat !== null) return answer.prettyFormat
const raw = answer.answer
if (raw === null || raw === undefined) return null
if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {
return String(raw)
}
return JSON.stringify(raw)
}
/**
* Answers are keyed by question id, which is useless downstream without the form's
* question list. `values` re-keys them by question label with the answer rendered as
* text `prettyFormat` when Jotform supplies one, the raw scalar otherwise so a
* submission can be read without a second call.
*
* Labels are not unique: a form can carry two questions both labelled "Email", and
* keying on the label alone would silently drop all but the last. So a label is used
* bare only when it appears once on the submission; every occurrence of a repeated
* label is suffixed with its question id instead. Disambiguating *every* occurrence
* rather than only the later ones keeps the result independent of answer order and
* makes a newly duplicated label read as absent rather than as an arbitrary winner.
* `answers` remains the complete, id-keyed record either way.
*/
function buildValues(answers: Record<string, JotformSubmissionAnswer>): Record<string, string> {
const values: Record<string, string> = {}
const labelCounts = new Map<string, number>()
for (const answer of Object.values(answers)) {
if (!answer.text) continue
if (answer.prettyFormat !== null) {
values[answer.text] = answer.prettyFormat
continue
}
const raw = answer.answer
if (raw === null || raw === undefined) continue
if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {
values[answer.text] = String(raw)
} else {
values[answer.text] = JSON.stringify(raw)
}
labelCounts.set(answer.text, (labelCounts.get(answer.text) ?? 0) + 1)
}
const values: Record<string, string> = {}
for (const [qid, answer] of Object.entries(answers)) {
if (!answer.text) continue
const rendered = renderAnswer(answer)
if (rendered === null) continue
const key = (labelCounts.get(answer.text) ?? 0) > 1 ? `${answer.text} (${qid})` : answer.text
values[key] = rendered
}
return values
}
+5 -1
View File
@@ -56,7 +56,11 @@ export interface JotformSubmission {
new: string | null
workflowStatus: string | null
answers: Record<string, JotformSubmissionAnswer>
/** Answers re-keyed by question label with each value rendered as text. */
/**
* Answers re-keyed by question label with each value rendered as text. Labels are
* not unique, so every occurrence of a repeated label is suffixed with its question
* id rather than overwriting.
*/
values: Record<string, string>
}