Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/api/ai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* AI API Layer
*
* Maps to the actual backend endpoints:
* - POST /forms/ai-generate → AI form generation (creates form + fields server-side)
* - POST /forms/:id/analytics → AI analytics report for a form
*/

const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'

// ---- Helpers ----

async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const errorData = (await response.json().catch(() => ({}))) as {
message?: string
}
throw new Error(
errorData.message || `Request failed: ${response.statusText}`,
)
}
const result = (await response.json()) as {
success: boolean
data: T
message?: string
}
if (!result.success) {
throw new Error(result.message || 'Request failed')
}
return result.data
}

// ---- Exported API ----

export interface AIGeneratedField {
id: string
fieldName: string
label: string
fieldType: string
fieldValueType: string
validation?: Record<string, unknown>
options?: Array<string>
}

export interface AIGeneratedForm {
id: string
title: string
description: string
isPublished: boolean
createdAt: string
fields: Array<AIGeneratedField>
}

export interface AnalyticsInsight {
question: string
metric: string
value: string | number
}

export interface AnalyticsTheme {
theme: string
description: string
frequency: string
}

export interface AnalyticsReport {
totalResponsesAnalyzed: number
executiveSummary: string
quantitativeInsights: Array<AnalyticsInsight>
qualitativeThemes: Array<AnalyticsTheme>
}

export const aiApi = {
/**
* Generates a complete form (title + fields) from a text prompt.
* The backend creates the form and all fields in a single transaction.
* POST /forms/ai-generate
* Body: { prompt: string }
* Returns: { id, title, description, fields, ... }
*/
generateForm: async (prompt: string): Promise<AIGeneratedForm> => {
const response = await fetch(`${API_URL}/forms/ai-generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
credentials: 'include',
})
return handleResponse<AIGeneratedForm>(response)
},

/**
* Generates an AI analytics report for a specific form's responses.
* POST /forms/:formId/analytics
* Body: {} (formId is in the URL)
* Query: ?format=json (default)
* Returns: AnalyticsReport
*/
generateSummary: async (formId: string): Promise<AnalyticsReport> => {
const response = await fetch(`${API_URL}/forms/${formId}/analytics`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
credentials: 'include',
})
return handleResponse<AnalyticsReport>(response)
},

/**
* Placeholder — no backend endpoint exists yet.
* Returns an empty array so AISuggestionPanel renders gracefully.
*/
suggestFields: (
_fields: Array<string>,
): Promise<{ suggestions: Array<{ label: string; type: string }> }> => {
return Promise.resolve({ suggestions: [] })
},
}
4 changes: 2 additions & 2 deletions src/api/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ async function handleResponse<T>(response: Response): Promise<T> {
// Fallbacks ensure we always have some error to show
throw new Error(
errorData.message ||
errorData.error ||
`Request failed: ${response.statusText}`,
errorData.error ||
`Request failed: ${response.statusText}`,
)
}

Expand Down
4 changes: 3 additions & 1 deletion src/api/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,9 @@ export const responsesApi = {

// GET /responses/received - Get all responses RECEIVED for forms owned by the user
// Falls back to fetching per-form if endpoint doesn't exist on deployed backend
getAllReceived: async (formIds?: Array<string>): Promise<Array<ReceivedResponse>> => {
getAllReceived: async (
formIds?: Array<string>,
): Promise<Array<ReceivedResponse>> => {
try {
const response = await fetch(`${API_URL}/responses/received`, {
method: 'GET',
Expand Down
52 changes: 27 additions & 25 deletions src/components/editor-sidebar-tabs.test.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,35 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { TabsLine } from './editor-sidebar-tabs';
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { TabsLine } from './editor-sidebar-tabs'

describe('TabsLine', () => {
it('renders the initial tab (Fields) correctly', () => {
render(<TabsLine />);
expect(screen.getByText('Short Text')).toBeInTheDocument();
});
const mockProps = {}

it('switches tabs correctly', async () => {
const user = userEvent.setup();
render(<TabsLine />);
it('renders the initial tab (Fields) correctly', () => {
render(<TabsLine {...mockProps} />)
expect(screen.getByText('Short Text')).toBeInTheDocument()
})

const templatesTab = screen.getByRole('tab', { name: /templates/i });
await user.click(templatesTab);
it('switches tabs correctly', async () => {
const user = userEvent.setup()
render(<TabsLine {...mockProps} />)

expect(screen.getByText('Contact Us Form')).toBeInTheDocument();
expect(screen.queryByText('Short Text')).not.toBeInTheDocument();
});
const templatesTab = screen.getByRole('tab', { name: /templates/i })
await user.click(templatesTab)

it('calls onFieldClick when a field is clicked', async () => {
const onFieldClick = vi.fn();
const user = userEvent.setup();
render(<TabsLine onFieldClick={onFieldClick} />);
expect(screen.getByText('Contact Us Form')).toBeInTheDocument()
expect(screen.queryByText('Short Text')).not.toBeInTheDocument()
})

const textFieldButton = screen.getByText('Short Text');
await user.click(textFieldButton);
it('calls onFieldClick when a field is clicked', async () => {
const onFieldClick = vi.fn()
const user = userEvent.setup()
render(<TabsLine {...mockProps} onFieldClick={onFieldClick} />)

expect(onFieldClick).toHaveBeenCalledWith('text');
});
});
const textFieldButton = screen.getByText('Short Text')
await user.click(textFieldButton)

expect(onFieldClick).toHaveBeenCalledWith('text')
})
})
61 changes: 56 additions & 5 deletions src/components/editor-sidebar-tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
import { useState } from 'react'
import { Loader2, Sparkles } from 'lucide-react'
import { FieldItems } from './fields/field-items'
import { TemplateItems } from './fields/template-items'
import type { Template } from '@/api/templates'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'

interface TabsLineProps {
onFieldClick?: (fieldId: string) => void
onTemplateClick?: (template: Template) => void
onAIGenerate?: (prompt: string) => void
isAIGenerating?: boolean
}

export function TabsLine({ onFieldClick, onTemplateClick }: TabsLineProps) {
export function TabsLine({
onFieldClick,
onTemplateClick,
onAIGenerate,
isAIGenerating,
}: TabsLineProps) {
const [prompt, setPrompt] = useState('')

const handleGenerate = () => {
if (prompt.trim() && onAIGenerate) {
onAIGenerate(prompt.trim())
}
}

return (
<Tabs defaultValue="fields" className="w-full h-full">
<TabsList variant="line" className="flex justify-center w-full">
<Tabs defaultValue="fields" className="w-full h-full flex flex-col">
<TabsList variant="line" className="flex justify-center w-full shrink-0">
<TabsTrigger value="fields">Fields</TabsTrigger>
<TabsTrigger value="templates">Templates</TabsTrigger>
<TabsTrigger value="generate">Generate</TabsTrigger>
Expand All @@ -33,9 +51,42 @@ export function TabsLine({ onFieldClick, onTemplateClick }: TabsLineProps) {

<TabsContent
value="generate"
className="p-4 text-sm text-muted-foreground text-center"
className="mt-0 flex-1 overflow-y-auto min-h-0 p-4"
>
Generate coming soon
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold">
<Sparkles className="h-4 w-4 text-primary" />
AI Form Generator
</div>
<p className="text-xs text-muted-foreground">
Describe the form you want to create and AI will generate it with appropriate fields. A new form will be created that you can then edit.
</p>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="e.g. Create a job application form with fields for name, email, resume upload, work experience, and education..."
className="w-full min-h-[120px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 resize-none"
rows={5}
disabled={isAIGenerating}
/>
<Button
onClick={handleGenerate}
disabled={!prompt.trim() || isAIGenerating}
className="w-full gap-2"
>
{isAIGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Generating...
</>
) : (
<>
<Sparkles className="h-4 w-4" />
Generate Form
</>
)}
</Button>
</div>
</TabsContent>
</Tabs>
)
Expand Down
11 changes: 10 additions & 1 deletion src/components/field-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,24 @@ import type { Template } from '@/api/templates'
interface FieldSidebarProps {
onFieldClick?: (fieldId: string) => void
onTemplateClick?: (template: Template) => void
onAIGenerate?: (prompt: string) => void
isAIGenerating?: boolean
}

export function FieldSidebar({
onFieldClick,
onTemplateClick,
onAIGenerate,
isAIGenerating,
}: FieldSidebarProps) {
return (
<div className="h-full w-full flex flex-col">
<TabsLine onFieldClick={onFieldClick} onTemplateClick={onTemplateClick} />
<TabsLine
onFieldClick={onFieldClick}
onTemplateClick={onTemplateClick}
onAIGenerate={onAIGenerate}
isAIGenerating={isAIGenerating}
/>
</div>
)
}
Loading
Loading