feat(typeform): add Typeform integration ref (#207)

* feat(typeform): add Typeform integration ref #106

* fix(typeform): icon

---------

Co-authored-by: rodrick-mpofu <rodrickmpofu@gmail.com>
This commit is contained in:
Emir Karabeg
2025-03-30 18:04:31 -07:00
committed by GitHub
parent eed8f16500
commit e14bb91bec
12 changed files with 1681 additions and 0 deletions

View File

@@ -0,0 +1,233 @@
import { TypeformIcon } from '@/components/icons'
import { ToolResponse } from '@/tools/types'
import { BlockConfig } from '../types'
interface TypeformResponse extends ToolResponse {
output: {
total_items: number
page_count: number
items: Array<{
landing_id: string
token: string
landed_at: string
submitted_at: string
metadata: {
user_agent: string
platform: string
referer: string
network_id: string
browser: string
}
answers: Array<{
field: {
id: string
type: string
ref: string
}
type: string
[key: string]: any // For different answer types (text, boolean, number, etc.)
}>
hidden: Record<string, any>
calculated: {
score: number
}
variables: Array<{
key: string
type: string
[key: string]: any // For different variable types
}>
}>
} | {
fileUrl: string
contentType: string
filename: string
} | {
fields: Array<{
dropoffs: number
id: string
label: string
ref: string
title: string
type: string
views: number
}>
form: {
platforms: Array<{
average_time: number
completion_rate: number
platform: string
responses_count: number
total_visits: number
unique_visits: number
}>
summary: {
average_time: number
completion_rate: number
responses_count: number
total_visits: number
unique_visits: number
}
}
}
}
export const TypeformBlock: BlockConfig<TypeformResponse> = {
type: 'typeform',
name: 'Typeform',
description: 'Interact with Typeform',
longDescription:
'Access and retrieve responses from your Typeform forms. Integrate form submissions data into your workflow for analysis, storage, or processing.',
category: 'tools',
bgColor: '#262627', // Typeform brand color
icon: TypeformIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
layout: 'full',
options: [
{ label: 'Retrieve Responses', id: 'typeform_responses' },
{ label: 'Download File', id: 'typeform_files' },
{ label: 'Form Insights', id: 'typeform_insights' },
],
value: () => 'typeform_responses',
},
{
id: 'formId',
title: 'Form ID',
type: 'short-input',
layout: 'full',
placeholder: 'Enter your Typeform form ID',
},
{
id: 'apiKey',
title: 'Personal Access Token',
type: 'short-input',
layout: 'full',
placeholder: 'Enter your Typeform personal access token',
password: true,
},
// Response operation fields
{
id: 'pageSize',
title: 'Page Size',
type: 'short-input',
layout: 'half',
placeholder: 'Number of responses per page (default: 25)',
condition: { field: 'operation', value: 'typeform_responses' },
},
{
id: 'since',
title: 'Since',
type: 'short-input',
layout: 'half',
placeholder: 'Retrieve responses after this date (ISO format)',
condition: { field: 'operation', value: 'typeform_responses' },
},
{
id: 'until',
title: 'Until',
type: 'short-input',
layout: 'half',
placeholder: 'Retrieve responses before this date (ISO format)',
condition: { field: 'operation', value: 'typeform_responses' },
},
{
id: 'completed',
title: 'Completed',
type: 'dropdown',
layout: 'half',
options: [
{ label: 'All Responses', id: 'all' },
{ label: 'Only Completed', id: 'true' },
{ label: 'Only Incomplete', id: 'false' },
],
condition: { field: 'operation', value: 'typeform_responses' },
},
// File operation fields
{
id: 'responseId',
title: 'Response ID',
type: 'short-input',
layout: 'full',
placeholder: 'Enter response ID (token)',
condition: { field: 'operation', value: 'typeform_files' },
},
{
id: 'fieldId',
title: 'Field ID',
type: 'short-input',
layout: 'half',
placeholder: 'Enter file upload field ID',
condition: { field: 'operation', value: 'typeform_files' },
},
{
id: 'filename',
title: 'Filename',
type: 'short-input',
layout: 'half',
placeholder: 'Enter exact filename of the file',
condition: { field: 'operation', value: 'typeform_files' },
},
{
id: 'inline',
title: 'Inline Display',
type: 'switch',
layout: 'half',
condition: { field: 'operation', value: 'typeform_files' },
},
],
tools: {
access: ['typeform_responses', 'typeform_files', 'typeform_insights'],
config: {
tool: (params) => {
switch (params.operation) {
case 'typeform_responses':
return 'typeform_responses'
case 'typeform_files':
return 'typeform_files'
case 'typeform_insights':
return 'typeform_insights'
default:
return 'typeform_responses'
}
},
},
},
inputs: {
operation: { type: 'string', required: true },
formId: { type: 'string', required: true },
apiKey: { type: 'string', required: true },
// Response operation params
pageSize: { type: 'number', required: false },
since: { type: 'string', required: false },
until: { type: 'string', required: false },
completed: { type: 'string', required: false },
// File operation params
responseId: { type: 'string', required: false },
fieldId: { type: 'string', required: false },
filename: { type: 'string', required: false },
inline: { type: 'boolean', required: false },
},
outputs: {
response: {
type: {
total_items: 'number',
page_count: 'number',
items: 'json',
},
dependsOn: {
subBlockId: 'operation',
condition: {
whenEmpty: {
total_items: 'number',
page_count: 'number',
items: 'json',
},
whenFilled: 'json',
},
},
},
},
}

View File

@@ -27,6 +27,7 @@ import { StarterBlock } from './blocks/starter'
import { SupabaseBlock } from './blocks/supabase'
import { TavilyBlock } from './blocks/tavily'
import { TranslateBlock } from './blocks/translate'
import { TypeformBlock } from './blocks/typeform'
import { VisionBlock } from './blocks/vision'
import { WhatsAppBlock } from './blocks/whatsapp'
import { XBlock } from './blocks/x'
@@ -67,6 +68,7 @@ export {
PerplexityBlock,
ConfluenceBlock,
ImageGeneratorBlock,
TypeformBlock,
}
// Registry of all block configurations, alphabetically sorted
@@ -99,6 +101,7 @@ const blocks: Record<string, BlockConfig> = {
supabase: SupabaseBlock,
tavily: TavilyBlock,
translate: TranslateBlock,
typeform: TypeformBlock,
vision: VisionBlock,
whatsapp: WhatsAppBlock,
x: XBlock,

View File

@@ -1756,3 +1756,21 @@ export function ImageIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function TypeformIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g transform="translate(1, 4)">
<rect x="0" y="0" width="5" height="16" rx="2.5" fill="currentColor" />
<rect x="8" y="0" width="14" height="16" rx="4" fill="currentColor" />
</g>
</svg>
)
}

View File

@@ -36,6 +36,7 @@ import { sheetsReadTool, sheetsUpdateTool, sheetsWriteTool } from './sheets'
import { slackMessageTool } from './slack/message'
import { supabaseInsertTool, supabaseQueryTool, supabaseUpdateTool } from './supabase'
import { tavilyExtractTool, tavilySearchTool } from './tavily'
import { typeformFilesTool, typeformInsightsTool, typeformResponsesTool } from './typeform'
import { OAuthTokenPayload, ToolConfig, ToolResponse } from './types'
import { formatRequestParams, validateToolRequest } from './utils'
import { visionTool } from './vision/vision'
@@ -65,6 +66,9 @@ export const tools: Record<string, ToolConfig> = {
supabase_query: supabaseQueryTool,
supabase_insert: supabaseInsertTool,
supabase_update: supabaseUpdateTool,
typeform_responses: typeformResponsesTool,
typeform_files: typeformFilesTool,
typeform_insights: typeformInsightsTool,
youtube_search: youtubeSearchTool,
notion_read: notionReadTool,
notion_write: notionWriteTool,

View File

@@ -0,0 +1,194 @@
/**
* @vitest-environment jsdom
*
* Typeform Files Tool Unit Tests
*
* This file contains unit tests for the Typeform Files tool,
* which is used to download files uploaded in Typeform responses.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { ToolTester } from '../__test-utils__/test-tools'
import { filesTool } from './files'
describe('Typeform Files Tool', () => {
let tester: ToolTester
// Mock file response
const mockFileResponseHeaders = {
'content-type': 'application/pdf',
'content-disposition': 'attachment; filename="test-file.pdf"'
}
beforeEach(() => {
tester = new ToolTester(filesTool)
})
afterEach(() => {
tester.cleanup()
vi.resetAllMocks()
})
describe('URL Construction', () => {
test('should construct correct URL for file endpoint', () => {
const params = {
formId: 'form123',
responseId: 'resp456',
fieldId: 'field789',
filename: 'test-file.pdf',
apiKey: 'test-token'
}
expect(tester.getRequestUrl(params)).toBe(
'https://api.typeform.com/forms/form123/responses/resp456/fields/field789/files/test-file.pdf'
)
})
test('should add inline parameter when provided', () => {
const params = {
formId: 'form123',
responseId: 'resp456',
fieldId: 'field789',
filename: 'test-file.pdf',
inline: true,
apiKey: 'test-token'
}
const url = tester.getRequestUrl(params)
expect(url).toContain('?inline=true')
})
test('should handle special characters in form ID and response ID', () => {
const params = {
formId: 'form/with/special?chars',
responseId: 'resp&with#chars',
fieldId: 'field-id',
filename: 'file name.pdf',
apiKey: 'test-token'
}
const url = tester.getRequestUrl(params)
// Just verify the URL is constructed and doesn't throw errors
expect(url).toContain('https://api.typeform.com/forms/')
expect(url).toContain('files')
})
})
describe('Headers Construction', () => {
test('should include correct authorization header', () => {
const params = {
formId: 'form123',
responseId: 'resp456',
fieldId: 'field789',
filename: 'test-file.pdf',
apiKey: 'test-token'
}
const headers = tester.getRequestHeaders(params)
expect(headers.Authorization).toBe('Bearer test-token')
expect(headers['Content-Type']).toBe('application/json')
})
})
describe('Data Transformation', () => {
test('should transform file data correctly', async () => {
// Setup mock response for binary file data
tester.setup('file-content-binary-data', {
headers: mockFileResponseHeaders
})
// Execute the tool
const result = await tester.execute({
formId: 'form123',
responseId: 'resp456',
fieldId: 'field789',
filename: 'test-file.pdf',
apiKey: 'test-token'
})
// Check the result
expect(result.success).toBe(true)
expect(result.output.filename).toBe('test-file.pdf')
expect(result.output.contentType).toBe('application/pdf')
// Don't check the fileUrl property as it depends on implementation details
})
test('should handle missing content-disposition header', async () => {
// Setup mock response without content-disposition
tester.setup('file-content-binary-data', {
headers: { 'content-type': 'application/pdf' }
})
// Execute the tool
const result = await tester.execute({
formId: 'form123',
responseId: 'resp456',
fieldId: 'field789',
filename: 'test-file.pdf',
apiKey: 'test-token'
})
// Check the result
expect(result.success).toBe(true)
expect(result.output.contentType).toBe('application/pdf')
// Don't check the fileUrl property as it depends on implementation details
// filename should be empty since there's no content-disposition
expect(result.output.filename).toBe('')
})
})
describe('Error Handling', () => {
test('should handle file not found errors', async () => {
// Setup 404 error response
tester.setup({ message: 'File not found' }, { ok: false, status: 404 })
// Execute the tool
const result = await tester.execute({
formId: 'form123',
responseId: 'resp456',
fieldId: 'field789',
filename: 'nonexistent.pdf',
apiKey: 'test-token'
})
// Check error handling
expect(result.success).toBe(false)
expect(result.error).toContain('Not Found')
})
test('should handle unauthorized errors', async () => {
// Setup 401 error response
tester.setup({ message: 'Unauthorized access' }, { ok: false, status: 401 })
// Execute the tool
const result = await tester.execute({
formId: 'form123',
responseId: 'resp456',
fieldId: 'field789',
filename: 'test-file.pdf',
apiKey: 'invalid-token'
})
// Check error handling
expect(result.success).toBe(false)
expect(result.error).toContain('Unauthorized')
})
test('should handle network errors', async () => {
// Setup network error
tester.setupError('Network error')
// Execute the tool
const result = await tester.execute({
formId: 'form123',
responseId: 'resp456',
fieldId: 'field789',
filename: 'test-file.pdf',
apiKey: 'test-token'
})
// Check error handling
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
})
})
})

147
sim/tools/typeform/files.ts Normal file
View File

@@ -0,0 +1,147 @@
import { ToolConfig, ToolResponse } from '../types'
interface TypeformFilesParams {
formId: string
responseId: string
fieldId: string
filename: string
inline?: boolean
apiKey: string
}
interface TypeformFilesResponse extends ToolResponse {
output: {
fileUrl: string
contentType: string
filename: string
}
}
export const filesTool: ToolConfig<TypeformFilesParams, TypeformFilesResponse> = {
id: 'typeform_files',
name: 'Typeform Files',
description: 'Download files uploaded in Typeform responses',
version: '1.0.0',
params: {
formId: {
type: 'string',
required: true,
description: 'Typeform form ID',
},
responseId: {
type: 'string',
required: true,
description: 'Response ID containing the files',
},
fieldId: {
type: 'string',
required: true,
description: 'Unique ID of the file upload field',
},
filename: {
type: 'string',
required: true,
description: 'Filename of the uploaded file',
},
inline: {
type: 'boolean',
required: false,
description: 'Whether to request the file with inline Content-Disposition',
},
apiKey: {
type: 'string',
required: true,
description: 'Typeform Personal Access Token',
},
},
request: {
url: (params: TypeformFilesParams) => {
const encodedFormId = encodeURIComponent(params.formId)
const encodedResponseId = encodeURIComponent(params.responseId)
const encodedFieldId = encodeURIComponent(params.fieldId)
const encodedFilename = encodeURIComponent(params.filename)
let url = `https://api.typeform.com/forms/${encodedFormId}/responses/${encodedResponseId}/fields/${encodedFieldId}/files/${encodedFilename}`
// Add the inline parameter if provided
if (params.inline !== undefined) {
url += `?inline=${params.inline}`
}
return url
},
method: 'GET',
headers: (params) => ({
'Authorization': `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response, params?: TypeformFilesParams) => {
if (!response.ok) {
let errorMessage = response.statusText || 'Unknown error';
try {
const errorData = await response.json();
if (errorData && errorData.message) {
errorMessage = errorData.message;
} else if (errorData && errorData.description) {
errorMessage = errorData.description;
} else if (typeof errorData === 'string') {
errorMessage = errorData;
}
} catch (e) {
// If we can't parse the error as JSON, just use the status text
}
throw new Error(`Typeform API error (${response.status}): ${errorMessage}`);
}
// For file downloads, we get the file directly
const contentType = response.headers.get('content-type') || 'application/octet-stream';
const contentDisposition = response.headers.get('content-disposition') || '';
// Try to extract filename from content-disposition if possible
let filename = '';
const filenameMatch = contentDisposition.match(/filename="(.+?)"/);
if (filenameMatch && filenameMatch[1]) {
filename = filenameMatch[1];
}
// Get file URL from the response URL or construct it from parameters if not available
let fileUrl = response.url;
// If the response URL is not available (common in test environments), construct it from params
if (!fileUrl && params) {
const encodedFormId = encodeURIComponent(params.formId);
const encodedResponseId = encodeURIComponent(params.responseId);
const encodedFieldId = encodeURIComponent(params.fieldId);
const encodedFilename = encodeURIComponent(params.filename);
fileUrl = `https://api.typeform.com/forms/${encodedFormId}/responses/${encodedResponseId}/fields/${encodedFieldId}/files/${encodedFilename}`;
if (params.inline !== undefined) {
fileUrl += `?inline=${params.inline}`;
}
}
return {
success: true,
output: {
fileUrl: fileUrl || '',
contentType,
filename
}
};
},
transformError: (error) => {
if (error instanceof Error) {
return `Failed to retrieve Typeform file: ${error.message}`
}
if (typeof error === 'object' && error !== null) {
return `Failed to retrieve Typeform file: ${JSON.stringify(error)}`
}
return `Failed to retrieve Typeform file: An unknown error occurred`
},
}

View File

@@ -0,0 +1,325 @@
/**
* @vitest-environment jsdom
*
* Typeform Integration Tests
*
* This file contains integration tests that verify the Typeform tools
* work correctly together and can be properly used from the block.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { typeformFilesTool, typeformResponsesTool, typeformInsightsTool } from './index'
import { ToolTester } from '../__test-utils__/test-tools'
describe('Typeform Tools Integration', () => {
describe('Typeform Responses Tool Export', () => {
let tester: ToolTester
beforeEach(() => {
tester = new ToolTester(typeformResponsesTool)
})
afterEach(() => {
tester.cleanup()
vi.resetAllMocks()
})
test('should use the correct tool ID', () => {
expect(typeformResponsesTool.id).toBe('typeform_responses')
})
test('should handle basic responses request', async () => {
// Setup mock response data
const mockData = {
total_items: 1,
page_count: 1,
items: [
{
landing_id: 'test-landing',
token: 'test-token',
submitted_at: '2023-01-01T00:00:00Z',
answers: [],
},
],
}
tester.setup(mockData)
// Execute the tool
const result = await tester.execute({
formId: 'test-form',
apiKey: 'test-api-key',
})
expect(result.success).toBe(true)
expect(result.output.total_items).toBe(1)
})
})
describe('Typeform Files Tool Export', () => {
let tester: ToolTester
beforeEach(() => {
tester = new ToolTester(typeformFilesTool)
})
afterEach(() => {
tester.cleanup()
vi.resetAllMocks()
})
test('should use the correct tool ID', () => {
expect(typeformFilesTool.id).toBe('typeform_files')
})
test('should handle basic file request', async () => {
// Setup mock response with file headers
tester.setup('binary-file-content', {
headers: {
'content-type': 'application/pdf',
'content-disposition': 'attachment; filename="test.pdf"'
}
})
// Execute the tool
const result = await tester.execute({
formId: 'test-form',
responseId: 'test-response',
fieldId: 'test-field',
filename: 'test.pdf',
apiKey: 'test-api-key',
})
expect(result.success).toBe(true)
expect(result.output.contentType).toBe('application/pdf')
expect(result.output.filename).toBe('test.pdf')
})
})
describe('Typeform Insights Tool Export', () => {
let tester: ToolTester
beforeEach(() => {
tester = new ToolTester(typeformInsightsTool)
})
afterEach(() => {
tester.cleanup()
vi.resetAllMocks()
})
test('should use the correct tool ID', () => {
expect(typeformInsightsTool.id).toBe('typeform_insights')
})
test('should handle basic insights request', async () => {
// Setup mock response data
const mockData = {
fields: [
{
dropoffs: 5,
id: 'field123',
label: '1',
ref: 'ref123',
title: 'What is your name?',
type: 'short_text',
views: 100
}
],
form: {
platforms: [
{
average_time: 120000,
completion_rate: 75.5,
platform: 'desktop',
responses_count: 80,
total_visits: 120,
unique_visits: 100
}
],
summary: {
average_time: 140000,
completion_rate: 72.3,
responses_count: 120,
total_visits: 180,
unique_visits: 150
}
}
}
tester.setup(mockData)
// Execute the tool
const result = await tester.execute({
formId: 'test-form',
apiKey: 'test-api-key',
})
expect(result.success).toBe(true)
expect(result.output.form.summary.responses_count).toBe(120)
expect(result.output.fields).toHaveLength(1)
})
})
describe('End-to-End Flow', () => {
// This test simulates using both tools together in a workflow
test('should be able to get responses and then file', async () => {
// First set up responses tester
const responsesTester = new ToolTester(typeformResponsesTool)
// Mock responses data with a file upload
const mockResponsesData = {
total_items: 1,
page_count: 1,
items: [
{
landing_id: 'landing-id',
token: 'response-id',
submitted_at: '2023-01-01T00:00:00Z',
answers: [
{
field: {
id: 'file-field',
type: 'file_upload',
},
type: 'file_url',
file_url: 'https://example.com/placeholder.pdf',
},
],
},
],
}
responsesTester.setup(mockResponsesData)
// Get responses
const responsesResult = await responsesTester.execute({
formId: 'test-form',
apiKey: 'test-api-key',
})
expect(responsesResult.success).toBe(true)
// Now get the response ID and field ID
const responseId = responsesResult.output.items[0].token
expect(responseId).toBe('response-id')
const fieldId = responsesResult.output.items[0].answers[0].field.id
expect(fieldId).toBe('file-field')
// Now set up files tester
const filesTester = new ToolTester(typeformFilesTool)
// Mock file data
filesTester.setup('binary-file-data', {
headers: {
'content-type': 'application/pdf',
'content-disposition': 'attachment; filename="uploaded.pdf"'
}
})
// Get file using the response ID and field ID from previous request
const filesResult = await filesTester.execute({
formId: 'test-form',
responseId,
fieldId,
filename: 'uploaded.pdf',
apiKey: 'test-api-key',
})
expect(filesResult.success).toBe(true)
expect(filesResult.output.contentType).toBe('application/pdf')
expect(filesResult.output.filename).toBe('uploaded.pdf')
// Clean up
responsesTester.cleanup()
filesTester.cleanup()
})
test('should be able to get responses and then insights', async () => {
// First set up responses tester
const responsesTester = new ToolTester(typeformResponsesTool)
// Mock responses data
const mockResponsesData = {
total_items: 10,
page_count: 1,
items: [
{
landing_id: 'landing-id',
token: 'response-id',
submitted_at: '2023-01-01T00:00:00Z',
answers: [],
},
],
}
responsesTester.setup(mockResponsesData)
// Get responses
const responsesResult = await responsesTester.execute({
formId: 'test-form',
apiKey: 'test-api-key',
})
expect(responsesResult.success).toBe(true)
expect(responsesResult.output.total_items).toBe(10)
// Now set up insights tester
const insightsTester = new ToolTester(typeformInsightsTool)
// Mock insights data
const mockInsightsData = {
fields: [
{
dropoffs: 5,
id: 'field123',
label: '1',
ref: 'ref123',
title: 'What is your name?',
type: 'short_text',
views: 100
}
],
form: {
platforms: [
{
average_time: 120000,
completion_rate: 75.5,
platform: 'desktop',
responses_count: 80,
total_visits: 120,
unique_visits: 100
}
],
summary: {
average_time: 140000,
completion_rate: 72.3,
responses_count: 120,
total_visits: 180,
unique_visits: 150
}
}
}
insightsTester.setup(mockInsightsData)
// Get insights for the same form
const insightsResult = await insightsTester.execute({
formId: 'test-form',
apiKey: 'test-api-key',
})
expect(insightsResult.success).toBe(true)
expect(insightsResult.output.form.summary.responses_count).toBe(120)
// Verify we can analyze the data by looking at completion rates
expect(insightsResult.output.form.summary.completion_rate).toBe(72.3)
expect(insightsResult.output.form.platforms[0].platform).toBe('desktop')
// Clean up
responsesTester.cleanup()
insightsTester.cleanup()
})
})
})

View File

@@ -0,0 +1,7 @@
import { responsesTool } from './responses'
import { filesTool } from './files'
import { insightsTool } from './insights'
export const typeformResponsesTool = responsesTool
export const typeformFilesTool = filesTool
export const typeformInsightsTool = insightsTool

View File

@@ -0,0 +1,190 @@
/**
* @vitest-environment jsdom
*
* Typeform Insights Tool Unit Tests
*
* This file contains unit tests for the Typeform Insights tool,
* which is used to retrieve form insights and analytics.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { ToolTester } from '../__test-utils__/test-tools'
import { insightsTool } from './insights'
describe('Typeform Insights Tool', () => {
let tester: ToolTester
// Mock insights response
const mockInsightsData = {
fields: [
{
dropoffs: 5,
id: 'field123',
label: '1',
ref: 'ref123',
title: 'What is your name?',
type: 'short_text',
views: 100
},
{
dropoffs: 10,
id: 'field456',
label: '2',
ref: 'ref456',
title: 'How did you hear about us?',
type: 'multiple_choice',
views: 95
}
],
form: {
platforms: [
{
average_time: 120000,
completion_rate: 75.5,
platform: 'desktop',
responses_count: 80,
total_visits: 120,
unique_visits: 100
},
{
average_time: 180000,
completion_rate: 65.2,
platform: 'mobile',
responses_count: 40,
total_visits: 60,
unique_visits: 50
}
],
summary: {
average_time: 140000,
completion_rate: 72.3,
responses_count: 120,
total_visits: 180,
unique_visits: 150
}
}
}
beforeEach(() => {
tester = new ToolTester(insightsTool)
})
afterEach(() => {
tester.cleanup()
vi.resetAllMocks()
})
describe('URL Construction', () => {
test('should construct correct URL for insights endpoint', () => {
const params = {
formId: 'form123',
apiKey: 'test-token'
}
expect(tester.getRequestUrl(params)).toBe(
'https://api.typeform.com/insights/form123/summary'
)
})
test('should handle special characters in form ID', () => {
const params = {
formId: 'form/with/special?chars',
apiKey: 'test-token'
}
const url = tester.getRequestUrl(params)
// Just verify the URL is constructed and doesn't throw errors
expect(url).toContain('https://api.typeform.com/insights/')
expect(url).toContain('summary')
})
})
describe('Headers Construction', () => {
test('should include correct authorization header', () => {
const params = {
formId: 'form123',
apiKey: 'test-token'
}
const headers = tester.getRequestHeaders(params)
expect(headers.Authorization).toBe('Bearer test-token')
expect(headers['Content-Type']).toBe('application/json')
})
})
describe('Data Transformation', () => {
test('should transform insights data correctly', async () => {
// Setup mock response
tester.setup(mockInsightsData)
// Execute the tool
const result = await tester.execute({
formId: 'form123',
apiKey: 'test-token'
})
// Check the result
expect(result.success).toBe(true)
// Verify form summary data
expect(result.output.form.summary.responses_count).toBe(120)
expect(result.output.form.summary.completion_rate).toBe(72.3)
// Verify platforms data
expect(result.output.form.platforms).toHaveLength(2)
expect(result.output.form.platforms[0].platform).toBe('desktop')
expect(result.output.form.platforms[1].platform).toBe('mobile')
// Verify fields data
expect(result.output.fields).toHaveLength(2)
expect(result.output.fields[0].title).toBe('What is your name?')
expect(result.output.fields[1].title).toBe('How did you hear about us?')
})
})
describe('Error Handling', () => {
test('should handle form not found errors', async () => {
// Setup 404 error response
tester.setup({ message: 'Form not found' }, { ok: false, status: 404 })
// Execute the tool
const result = await tester.execute({
formId: 'nonexistent',
apiKey: 'test-token'
})
// Check error handling
expect(result.success).toBe(false)
expect(result.error).toContain('Not Found')
})
test('should handle unauthorized errors', async () => {
// Setup 401 error response
tester.setup({ message: 'Unauthorized access' }, { ok: false, status: 401 })
// Execute the tool
const result = await tester.execute({
formId: 'form123',
apiKey: 'invalid-token'
})
// Check error handling
expect(result.success).toBe(false)
expect(result.error).toContain('Unauthorized')
})
test('should handle network errors', async () => {
// Setup network error
tester.setupError('Network error')
// Execute the tool
const result = await tester.execute({
formId: 'form123',
apiKey: 'test-token'
})
// Check error handling
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
})
})
})

View File

@@ -0,0 +1,132 @@
import { ToolConfig, ToolResponse } from '../types'
interface TypeformInsightsParams {
formId: string
apiKey: string
}
// This is the actual output data structure from the API
interface TypeformInsightsData {
fields: Array<{
dropoffs: number
id: string
label: string
ref: string
title: string
type: string
views: number
}>
form: {
platforms: Array<{
average_time: number
completion_rate: number
platform: string
responses_count: number
total_visits: number
unique_visits: number
}>
summary: {
average_time: number
completion_rate: number
responses_count: number
total_visits: number
unique_visits: number
}
}
}
// The ToolResponse uses a union type to allow either successful data or empty object in error case
interface TypeformInsightsResponse extends ToolResponse {
output: TypeformInsightsData | Record<string, never>
}
export const insightsTool: ToolConfig<TypeformInsightsParams, TypeformInsightsResponse> = {
id: 'typeform_insights',
name: 'Typeform Insights',
description: 'Retrieve insights and analytics for Typeform forms',
version: '1.0.0',
params: {
formId: {
type: 'string',
required: true,
description: 'Typeform form ID',
},
apiKey: {
type: 'string',
required: true,
description: 'Typeform Personal Access Token',
},
},
request: {
url: (params: TypeformInsightsParams) => {
const encodedFormId = encodeURIComponent(params.formId)
return `https://api.typeform.com/insights/${encodedFormId}/summary`
},
method: 'GET',
headers: (params) => ({
'Authorization': `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
if (!response.ok) {
let errorMessage = response.statusText || 'Unknown error';
let errorDetails = '';
try {
const errorData = await response.json();
console.log('Typeform API error response:', JSON.stringify(errorData, null, 2));
if (errorData && errorData.message) {
errorMessage = errorData.message;
} else if (errorData && errorData.description) {
errorMessage = errorData.description;
} else if (typeof errorData === 'string') {
errorMessage = errorData;
}
// Extract more details if available
if (errorData && errorData.details) {
errorDetails = ` Details: ${JSON.stringify(errorData.details)}`;
}
// Special handling for 403 errors
if (response.status === 403) {
return {
success: false,
output: {},
error: `Access forbidden (403) to Typeform Insights API. This could be due to:
1. Missing 'read:insights' scope on your API token
2. Insufficient plan subscription (insights may require a higher plan)
3. No access rights to the specified form
4. API token is invalid or expired
Details from API: ${errorMessage}${errorDetails}`
};
}
} catch (e) {
// If we can't parse the error as JSON, just use the status text
console.log('Error parsing Typeform API error:', e);
}
throw new Error(`Typeform API error (${response.status}): ${errorMessage}${errorDetails}`);
}
const data = await response.json();
return {
success: true,
output: data
};
},
transformError: (error) => {
if (error instanceof Error) {
return `Failed to retrieve Typeform insights: ${error.message}`
}
if (typeof error === 'object' && error !== null) {
return `Failed to retrieve Typeform insights: ${JSON.stringify(error)}`
}
return `Failed to retrieve Typeform insights: An unknown error occurred`
},
}

View File

@@ -0,0 +1,269 @@
/**
* @vitest-environment jsdom
*
* Typeform Responses Tool Unit Tests
*
* This file contains unit tests for the Typeform Responses tool,
* which is used to fetch form responses from the Typeform API.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { ToolTester } from '../__test-utils__/test-tools'
import { responsesTool } from './responses'
describe('Typeform Responses Tool', () => {
let tester: ToolTester
// Mock response data
const mockResponsesData = {
total_items: 2,
page_count: 1,
items: [
{
landing_id: 'landing-id-1',
token: 'response-id-1',
landed_at: '2023-01-01T10:00:00Z',
submitted_at: '2023-01-01T10:05:00Z',
metadata: {
user_agent: 'Mozilla/5.0',
platform: 'web',
referer: 'https://example.com',
network_id: 'network-id-1',
browser: 'chrome',
},
answers: [
{
field: {
id: 'field-id-1',
type: 'short_text',
ref: 'ref-1',
},
type: 'text',
text: 'Sample answer',
},
],
hidden: {},
calculated: {
score: 0,
},
variables: [],
},
{
landing_id: 'landing-id-2',
token: 'response-id-2',
landed_at: '2023-01-02T10:00:00Z',
submitted_at: '2023-01-02T10:05:00Z',
metadata: {
user_agent: 'Mozilla/5.0',
platform: 'web',
referer: 'https://example.com',
network_id: 'network-id-2',
browser: 'chrome',
},
answers: [
{
field: {
id: 'field-id-1',
type: 'short_text',
ref: 'ref-1',
},
type: 'text',
text: 'Another answer',
},
],
hidden: {},
calculated: {
score: 0,
},
variables: [],
},
],
}
beforeEach(() => {
tester = new ToolTester(responsesTool)
})
afterEach(() => {
tester.cleanup()
vi.resetAllMocks()
})
describe('URL Construction', () => {
test('should construct correct base Typeform API URL', () => {
const params = {
formId: 'form123',
apiKey: 'test-token',
}
expect(tester.getRequestUrl(params)).toBe(
'https://api.typeform.com/forms/form123/responses'
)
})
test('should add pageSize parameter to URL when provided', () => {
const params = {
formId: 'form123',
apiKey: 'test-token',
pageSize: 50,
}
expect(tester.getRequestUrl(params)).toBe(
'https://api.typeform.com/forms/form123/responses?page_size=50'
)
})
test('should add since parameter to URL when provided', () => {
const params = {
formId: 'form123',
apiKey: 'test-token',
since: '2023-01-01T00:00:00Z',
}
const url = tester.getRequestUrl(params)
expect(url).toContain('https://api.typeform.com/forms/form123/responses?since=')
expect(url).toContain('2023-01-01T00:00:00Z')
})
test('should add until parameter to URL when provided', () => {
const params = {
formId: 'form123',
apiKey: 'test-token',
until: '2023-01-31T23:59:59Z',
}
const url = tester.getRequestUrl(params)
expect(url).toContain('https://api.typeform.com/forms/form123/responses?until=')
expect(url).toContain('2023-01-31T23:59:59Z')
})
test('should add completed parameter to URL when provided and not "all"', () => {
const params = {
formId: 'form123',
apiKey: 'test-token',
completed: 'true',
}
expect(tester.getRequestUrl(params)).toBe(
'https://api.typeform.com/forms/form123/responses?completed=true'
)
})
test('should not add completed parameter to URL when set to "all"', () => {
const params = {
formId: 'form123',
apiKey: 'test-token',
completed: 'all',
}
expect(tester.getRequestUrl(params)).toBe(
'https://api.typeform.com/forms/form123/responses'
)
})
test('should combine multiple parameters correctly', () => {
const params = {
formId: 'form123',
apiKey: 'test-token',
pageSize: 10,
since: '2023-01-01T00:00:00Z',
until: '2023-01-31T23:59:59Z',
completed: 'true',
}
const url = tester.getRequestUrl(params)
expect(url).toContain('https://api.typeform.com/forms/form123/responses?')
expect(url).toContain('page_size=10')
expect(url).toContain('since=')
expect(url).toContain('until=')
expect(url).toContain('completed=true')
})
})
describe('Headers Construction', () => {
test('should include correct authorization header', () => {
const params = {
formId: 'form123',
apiKey: 'test-token',
}
const headers = tester.getRequestHeaders(params)
expect(headers.Authorization).toBe('Bearer test-token')
expect(headers['Content-Type']).toBe('application/json')
})
})
describe('Data Transformation', () => {
test('should fetch and transform responses correctly', async () => {
// Setup mock response
tester.setup(mockResponsesData)
// Execute the tool
const result = await tester.execute({
formId: 'form123',
apiKey: 'test-token',
})
// Check the result
expect(result.success).toBe(true)
expect(result.output.total_items).toBe(2)
expect(result.output.items).toHaveLength(2)
// Check first response
const firstResponse = result.output.items[0]
expect(firstResponse.token).toBe('response-id-1')
expect(firstResponse.answers).toHaveLength(1)
expect(firstResponse.answers[0].text).toBe('Sample answer')
// Check second response
const secondResponse = result.output.items[1]
expect(secondResponse.token).toBe('response-id-2')
})
})
describe('Error Handling', () => {
test('should handle form not found errors', async () => {
// Setup 404 error response
tester.setup({ message: 'Form not found' }, { ok: false, status: 404 })
// Execute the tool
const result = await tester.execute({
formId: 'nonexistent-form',
apiKey: 'test-token',
})
// Check error handling
expect(result.success).toBe(false)
expect(result.error).toContain('Not Found')
})
test('should handle unauthorized errors', async () => {
// Setup 401 error response
tester.setup({ message: 'Unauthorized access' }, { ok: false, status: 401 })
// Execute the tool
const result = await tester.execute({
formId: 'form123',
apiKey: 'invalid-token',
})
// Check error handling
expect(result.success).toBe(false)
expect(result.error).toContain('Unauthorized')
})
test('should handle network errors', async () => {
// Setup network error
tester.setupError('Network error')
// Execute the tool
const result = await tester.execute({
formId: 'form123',
apiKey: 'test-token',
})
// Check error handling
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
})
})
})

View File

@@ -0,0 +1,159 @@
import { ToolConfig, ToolResponse } from '../types'
interface TypeformResponsesParams {
formId: string
apiKey: string
pageSize?: number
since?: string
until?: string
completed?: string
}
interface TypeformResponsesResponse extends ToolResponse {
output: {
total_items: number
page_count: number
items: Array<{
landing_id: string
token: string
landed_at: string
submitted_at: string
metadata: {
user_agent: string
platform: string
referer: string
network_id: string
browser: string
}
answers: Array<{
field: {
id: string
type: string
ref: string
}
type: string
[key: string]: any
}>
hidden: Record<string, any>
calculated: {
score: number
}
variables: Array<{
key: string
type: string
[key: string]: any
}>
}>
}
}
export const responsesTool: ToolConfig<TypeformResponsesParams, TypeformResponsesResponse> = {
id: 'typeform_responses',
name: 'Typeform Responses',
description: 'Retrieve form responses from Typeform',
version: '1.0.0',
params: {
formId: {
type: 'string',
required: true,
description: 'Typeform form ID',
},
apiKey: {
type: 'string',
required: true,
description: 'Typeform Personal Access Token',
},
pageSize: {
type: 'number',
required: false,
description: 'Number of responses to retrieve (default: 25)',
},
since: {
type: 'string',
required: false,
description: 'Retrieve responses submitted after this date (ISO 8601 format)',
},
until: {
type: 'string',
required: false,
description: 'Retrieve responses submitted before this date (ISO 8601 format)',
},
completed: {
type: 'string',
required: false,
description: 'Filter by completion status (true/false)',
},
},
request: {
url: (params: TypeformResponsesParams) => {
const url = `https://api.typeform.com/forms/${params.formId}/responses`
const queryParams = []
if (params.pageSize) {
queryParams.push(`page_size=${params.pageSize}`)
}
if (params.since) {
queryParams.push(`since=${encodeURIComponent(params.since)}`)
}
if (params.until) {
queryParams.push(`until=${encodeURIComponent(params.until)}`)
}
if (params.completed && params.completed !== 'all') {
queryParams.push(`completed=${params.completed}`)
}
return queryParams.length > 0 ? `${url}?${queryParams.join('&')}` : url
},
method: 'GET',
headers: (params) => ({
'Authorization': `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
if (!response.ok) {
let errorMessage = response.statusText || 'Unknown error';
try {
const errorData = await response.json();
if (errorData && errorData.message) {
errorMessage = errorData.message;
} else if (errorData && errorData.description) {
errorMessage = errorData.description;
} else if (typeof errorData === 'string') {
errorMessage = errorData;
}
} catch (e) {
// If we can't parse the error as JSON, just use the status text
}
throw new Error(`Typeform API error (${response.status}): ${errorMessage}`);
}
try {
const data = await response.json();
return {
success: true,
output: data,
};
} catch (error) {
throw new Error(`Failed to parse Typeform response: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
},
transformError: (error) => {
if (error instanceof Error) {
return `Failed to retrieve Typeform responses: ${error.message}`
}
if (typeof error === 'object' && error !== null) {
return `Failed to retrieve Typeform responses: ${JSON.stringify(error)}`
}
return `Failed to retrieve Typeform responses: An unknown error occurred`
},
}