mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-21 13:00:04 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user