Skip to content

Commit 0844d41

Browse files
authored
feat(jotform): add Jotform integration (#6772)
* feat(jotform): add Jotform integration Adds 43 tools covering forms, questions, submissions, reports, webhooks, labels, and account operations, plus the block, icon, and generated docs. Request shapes are pinned against the API's own curl samples and the official SDKs: PUT /form/{id}/properties and PUT /form/{id}/questions each take a named envelope while PUT /form and the bulk-submission PUT take their payload bare, and submission answers accept both the nested object and the documented {qid}_{subfield} shorthand. Skips the deprecated folder endpoints in favor of labels, and leaves out endpoints whose response shape the docs do not publish. * fix(jotform): harden the error envelope against quoted codes and non-JSON bodies Jotform quotes `responseCode` on some endpoints and not others, so a typeof-number test skipped the check on the quoted ones and turned an auth failure into a successful tool result with empty output. Also caps the raw body fallback, since an upstream gateway can answer with an HTML page instead of the documented envelope. * 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. * fix(jotform): make the label-keyed answer map collision-proof Question labels are free text, so the disambiguation key added in f9026cf was not itself safe: a question literally labelled "Email (3)" lands on the key generated for a duplicate "Email" at qid 3, dropping one of them. Any key already taken is now widened again until it is free. Accumulates in a Map rather than an object literal on the way out, since a question labelled `__proto__` assigned onto `{}` sets the prototype instead of an own property and disappears from the map entirely.
1 parent 11fe848 commit 0844d41

62 files changed

Lines changed: 8681 additions & 4 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/icons.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9140,6 +9140,35 @@ export function FlowiseIcon(props: SVGProps<SVGSVGElement>) {
91409140
)
91419141
}
91429142

9143+
export function JotformIcon(props: SVGProps<SVGSVGElement>) {
9144+
return (
9145+
<svg
9146+
{...props}
9147+
viewBox='147 132 306 336'
9148+
fill='none'
9149+
role='img'
9150+
xmlns='http://www.w3.org/2000/svg'
9151+
>
9152+
<path
9153+
fill='#0A1551'
9154+
d='M231.287 450.612C237.601 456.733 233.139 467.221 224.173 467.221H168.06C156.989 467.221 147.98 458.488 147.98 447.756V393.358C147.98 384.666 158.799 380.341 165.113 386.462L231.287 450.612Z'
9155+
/>
9156+
<path
9157+
fill='#FFB629'
9158+
d='M319.003 454.845C302.393 438.343 302.394 411.589 319.003 395.088L378.947 335.535C395.557 319.033 422.486 319.033 439.096 335.535C455.705 352.036 455.705 378.79 439.096 395.292L379.152 454.845C362.542 471.346 335.613 471.346 319.003 454.845Z'
9159+
/>
9160+
<path
9161+
fill='#0099FF'
9162+
d='M160.64 305.204C144.031 288.703 144.031 261.949 160.64 245.447L261.52 145.155C278.129 128.653 305.059 128.653 321.669 145.155C338.278 161.656 338.278 188.41 321.669 204.912L220.789 305.204C204.179 321.705 177.25 321.705 160.64 305.204Z'
9163+
/>
9164+
<path
9165+
fill='#FF6100'
9166+
d='M243.108 376.686C226.498 360.185 226.498 333.43 243.108 316.929L379.414 181.511C396.024 165.009 422.953 165.009 439.563 181.511C456.173 198.012 456.173 224.766 439.563 241.268L303.256 376.686C286.647 393.187 259.717 393.187 243.108 376.686Z'
9167+
/>
9168+
</svg>
9169+
)
9170+
}
9171+
91439172
export function JupyterIcon(props: SVGProps<SVGSVGElement>) {
91449173
return (
91459174
<svg {...props} viewBox='0 0 44 51' xmlns='http://www.w3.org/2000/svg'>

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ import {
124124
JinaAIIcon,
125125
JiraIcon,
126126
JiraServiceManagementIcon,
127+
JotformIcon,
127128
JupyterIcon,
128129
KalshiIcon,
129130
KetchIcon,
@@ -402,6 +403,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
402403
jina: JinaAIIcon,
403404
jira: JiraIcon,
404405
jira_service_management: JiraServiceManagementIcon,
406+
jotform: JotformIcon,
405407
jsm: JiraServiceManagementIcon,
406408
jupyter: JupyterIcon,
407409
kalshi: KalshiIcon,

apps/docs/content/docs/en/integrations/jotform.mdx

Lines changed: 1176 additions & 0 deletions
Large diffs are not rendered by default.

apps/docs/content/docs/en/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@
129129
"jina",
130130
"jira",
131131
"jira_service_management",
132+
"jotform",
132133
"jupyter",
133134
"kalshi",
134135
"ketch",
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { JotformBlock } from '@/blocks/blocks/jotform'
6+
7+
/**
8+
* Every assertion here runs against `{ ...inputs, ...buildParams(inputs) }`, the
9+
* shape the generic tool handler actually forwards. A key the mapper omits is
10+
* *not* dropped by that merge — the raw subBlock value survives — so asserting
11+
* on the mapper's return alone would prove nothing about what the tool receives.
12+
*/
13+
describe('JotformBlock', () => {
14+
const buildParams = JotformBlock.tools.config.params!
15+
const selectTool = JotformBlock.tools.config.tool!
16+
17+
const operationIds =
18+
JotformBlock.subBlocks
19+
.find((subBlock) => subBlock.id === 'operation')
20+
?.options?.map((option) => (option as { id: string }).id) ?? []
21+
22+
it('maps every dropdown operation onto a registered tool', () => {
23+
expect(operationIds).toHaveLength(43)
24+
expect(new Set(operationIds.map((id) => selectTool({ operation: id })))).toEqual(
25+
new Set(JotformBlock.tools.access)
26+
)
27+
})
28+
29+
it('declares an input for every subblock', () => {
30+
const inputIds = new Set(Object.keys(JotformBlock.inputs))
31+
const missing = JotformBlock.subBlocks
32+
.map((subBlock) => subBlock.id)
33+
.filter((id) => !inputIds.has(id))
34+
35+
expect(missing).toEqual([])
36+
})
37+
38+
it('gives every subblock a unique id', () => {
39+
const ids = JotformBlock.subBlocks.map((subBlock) => subBlock.id)
40+
expect(new Set(ids).size).toBe(ids.length)
41+
})
42+
43+
/**
44+
* `properties`, `text`, `order`, `name`, `title`, and `fields` are tool params on
45+
* one operation and would be meaningless on another. Naming a subblock after one
46+
* of them would leak its stale value into every other operation through the raw
47+
* merge, so each is deliberately carried by a prefixed subblock instead.
48+
*/
49+
it('never names a subblock after a param another operation owns', () => {
50+
const contested = ['properties', 'questions', 'emails', 'text', 'order', 'name', 'title']
51+
const collisions = JotformBlock.subBlocks
52+
.map((subBlock) => subBlock.id)
53+
.filter((id) => contested.includes(id))
54+
55+
expect(collisions).toEqual([])
56+
})
57+
58+
it('renames the create-form subblocks onto the tool params', () => {
59+
const inputs = {
60+
operation: 'create_form',
61+
apiKey: 'key',
62+
newFormQuestions: '[{"type":"control_email","text":"Email","order":"1","name":"email"}]',
63+
newFormProperties: '{"title":"Contact Us"}',
64+
newFormEmails: '[{"type":"notification","to":"team@example.com"}]',
65+
}
66+
const finalInputs = { ...inputs, ...buildParams(inputs) }
67+
68+
expect(finalInputs.questions).toBe(inputs.newFormQuestions)
69+
expect(finalInputs.properties).toBe(inputs.newFormProperties)
70+
expect(finalInputs.emails).toBe(inputs.newFormEmails)
71+
})
72+
73+
it('renames the question subblocks onto the tool params', () => {
74+
const inputs = {
75+
operation: 'create_question',
76+
apiKey: 'key',
77+
formId: '2315',
78+
questionType: 'control_email',
79+
questionText: 'Your email',
80+
questionOrder: '2',
81+
questionName: 'yourEmail',
82+
}
83+
const finalInputs = { ...inputs, ...buildParams(inputs) }
84+
85+
expect(finalInputs.text).toBe('Your email')
86+
expect(finalInputs.order).toBe('2')
87+
expect(finalInputs.name).toBe('yourEmail')
88+
expect(finalInputs.questionType).toBe('control_email')
89+
})
90+
91+
it('renames the report subblocks onto the tool params', () => {
92+
const inputs = {
93+
operation: 'create_report',
94+
apiKey: 'key',
95+
formId: '2315',
96+
reportTitle: 'Weekly responses',
97+
reportType: 'csv',
98+
reportFields: 'ip,dt,3,4',
99+
}
100+
const finalInputs = { ...inputs, ...buildParams(inputs) }
101+
102+
expect(finalInputs.title).toBe('Weekly responses')
103+
expect(finalInputs.listType).toBe('csv')
104+
expect(finalInputs.fields).toBe('ip,dt,3,4')
105+
})
106+
107+
it('renames the bulk subblocks onto the tool params', () => {
108+
const submissions = {
109+
operation: 'create_submissions',
110+
apiKey: 'key',
111+
formId: '2315',
112+
bulkSubmissions: '[{"1":{"text":"a"}}]',
113+
}
114+
expect({ ...submissions, ...buildParams(submissions) }.submissions).toBe(
115+
submissions.bulkSubmissions
116+
)
117+
118+
const questions = {
119+
operation: 'create_questions',
120+
apiKey: 'key',
121+
formId: '2315',
122+
bulkQuestions: '[{"type":"control_head"}]',
123+
}
124+
expect({ ...questions, ...buildParams(questions) }.questions).toBe(questions.bulkQuestions)
125+
})
126+
127+
/**
128+
* `create_form` and `create_questions` both feed a `questions` tool param from
129+
* different subblocks. A leftover value from one must not arrive as the other.
130+
*/
131+
it('keeps the two questions sources from bleeding into each other', () => {
132+
const inputs = {
133+
operation: 'create_questions',
134+
apiKey: 'key',
135+
formId: '2315',
136+
bulkQuestions: '[{"type":"control_head"}]',
137+
newFormQuestions: '[{"type":"control_email"}]',
138+
}
139+
140+
expect({ ...inputs, ...buildParams(inputs) }.questions).toBe(inputs.bulkQuestions)
141+
})
142+
143+
it('renames the history subblocks onto the tool params', () => {
144+
const inputs = {
145+
operation: 'get_history',
146+
apiKey: 'key',
147+
historyAction: 'formCreation',
148+
historySortBy: 'ASC',
149+
historyStartDate: '01/01/2026',
150+
historyEndDate: '02/01/2026',
151+
}
152+
const finalInputs = { ...inputs, ...buildParams(inputs) }
153+
154+
expect(finalInputs.action).toBe('formCreation')
155+
expect(finalInputs.sortBy).toBe('ASC')
156+
expect(finalInputs.startDate).toBe('01/01/2026')
157+
expect(finalInputs.endDate).toBe('02/01/2026')
158+
})
159+
160+
/**
161+
* A leftover value from a previously selected operation still reaches the mapper.
162+
* The rename must not fire for the operation that does not own it, or a stale
163+
* report title would arrive as a question label.
164+
*/
165+
it('leaves renames untouched for operations that do not own them', () => {
166+
const inputs = {
167+
operation: 'list_form_submissions',
168+
apiKey: 'key',
169+
formId: '2315',
170+
reportTitle: 'Left over from an earlier operation',
171+
questionText: 'Also left over',
172+
}
173+
const finalInputs = { ...inputs, ...buildParams(inputs) }
174+
175+
expect(finalInputs.title).toBeUndefined()
176+
expect(finalInputs.text).toBeUndefined()
177+
expect(finalInputs.listType).toBeUndefined()
178+
})
179+
180+
it('marks the identifier each operation addresses as required', () => {
181+
const requiredFor = (id: string) => {
182+
const subBlock = JotformBlock.subBlocks.find((candidate) => candidate.id === id)
183+
const required = subBlock?.required as { field: string; value: string | string[] } | undefined
184+
const value = required?.value ?? []
185+
return Array.isArray(value) ? value : [value]
186+
}
187+
188+
expect(requiredFor('formId')).toContain('list_form_submissions')
189+
expect(requiredFor('submissionId')).toEqual([
190+
'get_submission',
191+
'update_submission',
192+
'delete_submission',
193+
])
194+
expect(requiredFor('questionId')).toEqual([
195+
'get_question',
196+
'update_question',
197+
'delete_question',
198+
])
199+
expect(requiredFor('reportId')).toEqual(['get_report', 'delete_report'])
200+
expect(requiredFor('webhookId')).toEqual(['delete_webhook'])
201+
})
202+
})

0 commit comments

Comments
 (0)