mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-21 21:15:56 +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 |
|
| ↳ `new` | string | 1 when the submission is unread |
|
||||||
| ↳ `workflowStatus` | string | Approval state, present only when the form feeds a workflow |
|
| ↳ `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. |
|
| ↳ `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 |
|
| `pagination` | object | Result window reported by the API |
|
||||||
| ↳ `offset` | number | Index of the first returned submission |
|
| ↳ `offset` | number | Index of the first returned submission |
|
||||||
| ↳ `limit` | number | Page size applied |
|
| ↳ `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 |
|
| ↳ `new` | string | 1 when the submission is unread |
|
||||||
| ↳ `workflowStatus` | string | Approval state, present only when the form feeds a workflow |
|
| ↳ `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. |
|
| ↳ `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 |
|
| `pagination` | object | Result window reported by the API |
|
||||||
| ↳ `offset` | number | Index of the first returned submission |
|
| ↳ `offset` | number | Index of the first returned submission |
|
||||||
| ↳ `limit` | number | Page size applied |
|
| ↳ `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 |
|
| ↳ `new` | string | 1 when the submission is unread |
|
||||||
| ↳ `workflowStatus` | string | Approval state, present only when the form feeds a workflow |
|
| ↳ `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. |
|
| ↳ `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
|
### Jotform Create Submission
|
||||||
|
|
||||||
|
|||||||
@@ -864,11 +864,12 @@ export const JotformBlock: BlockConfig = {
|
|||||||
submissions: {
|
submissions: {
|
||||||
type: 'json',
|
type: 'json',
|
||||||
description:
|
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: {
|
submission: {
|
||||||
type: 'json',
|
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: {
|
submissionId: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -83,7 +83,7 @@ export const getSubmissionTool: ToolConfig<JotformGetSubmissionParams, JotformSu
|
|||||||
values: {
|
values: {
|
||||||
type: 'json',
|
type: 'json',
|
||||||
description:
|
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)
|
).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: {
|
values: {
|
||||||
type: 'json',
|
type: 'json',
|
||||||
description:
|
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: {
|
values: {
|
||||||
type: 'json',
|
type: 'json',
|
||||||
description:
|
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
|
* 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
|
* 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
|
* text — `prettyFormat` when Jotform supplies one, the raw scalar otherwise — so a
|
||||||
* submission can be read without a second call.
|
* 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> {
|
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)) {
|
for (const answer of Object.values(answers)) {
|
||||||
if (!answer.text) continue
|
if (!answer.text) continue
|
||||||
if (answer.prettyFormat !== null) {
|
labelCounts.set(answer.text, (labelCounts.get(answer.text) ?? 0) + 1)
|
||||||
values[answer.text] = answer.prettyFormat
|
}
|
||||||
continue
|
|
||||||
}
|
const values: Record<string, string> = {}
|
||||||
const raw = answer.answer
|
for (const [qid, answer] of Object.entries(answers)) {
|
||||||
if (raw === null || raw === undefined) continue
|
if (!answer.text) continue
|
||||||
if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {
|
const rendered = renderAnswer(answer)
|
||||||
values[answer.text] = String(raw)
|
if (rendered === null) continue
|
||||||
} else {
|
|
||||||
values[answer.text] = JSON.stringify(raw)
|
const key = (labelCounts.get(answer.text) ?? 0) > 1 ? `${answer.text} (${qid})` : answer.text
|
||||||
}
|
values[key] = rendered
|
||||||
}
|
}
|
||||||
return values
|
return values
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,11 @@ export interface JotformSubmission {
|
|||||||
new: string | null
|
new: string | null
|
||||||
workflowStatus: string | null
|
workflowStatus: string | null
|
||||||
answers: Record<string, JotformSubmissionAnswer>
|
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>
|
values: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user