diff --git a/src/api/ai.ts b/src/api/ai.ts new file mode 100644 index 00000000..6f7f2b46 --- /dev/null +++ b/src/api/ai.ts @@ -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(response: Response): Promise { + 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 + options?: Array +} + +export interface AIGeneratedForm { + id: string + title: string + description: string + isPublished: boolean + createdAt: string + fields: Array +} + +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 + qualitativeThemes: Array +} + +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 => { + const response = await fetch(`${API_URL}/forms/ai-generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt }), + credentials: 'include', + }) + return handleResponse(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 => { + const response = await fetch(`${API_URL}/forms/${formId}/analytics`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + credentials: 'include', + }) + return handleResponse(response) + }, + + /** + * Placeholder — no backend endpoint exists yet. + * Returns an empty array so AISuggestionPanel renders gracefully. + */ + suggestFields: ( + _fields: Array, + ): Promise<{ suggestions: Array<{ label: string; type: string }> }> => { + return Promise.resolve({ suggestions: [] }) + }, +} diff --git a/src/api/forms.ts b/src/api/forms.ts index 03e0becd..ef9fc583 100644 --- a/src/api/forms.ts +++ b/src/api/forms.ts @@ -117,8 +117,8 @@ async function handleResponse(response: Response): Promise { // 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}`, ) } diff --git a/src/api/responses.ts b/src/api/responses.ts index 4ddf3478..f8ef7afe 100644 --- a/src/api/responses.ts +++ b/src/api/responses.ts @@ -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): Promise> => { + getAllReceived: async ( + formIds?: Array, + ): Promise> => { try { const response = await fetch(`${API_URL}/responses/received`, { method: 'GET', diff --git a/src/components/editor-sidebar-tabs.test.tsx b/src/components/editor-sidebar-tabs.test.tsx index dfcaeb25..ade37998 100644 --- a/src/components/editor-sidebar-tabs.test.tsx +++ b/src/components/editor-sidebar-tabs.test.tsx @@ -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(); - expect(screen.getByText('Short Text')).toBeInTheDocument(); - }); + const mockProps = {} - it('switches tabs correctly', async () => { - const user = userEvent.setup(); - render(); + it('renders the initial tab (Fields) correctly', () => { + render() + 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() - 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(); + 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() - expect(onFieldClick).toHaveBeenCalledWith('text'); - }); -}); + const textFieldButton = screen.getByText('Short Text') + await user.click(textFieldButton) + + expect(onFieldClick).toHaveBeenCalledWith('text') + }) +}) diff --git a/src/components/editor-sidebar-tabs.tsx b/src/components/editor-sidebar-tabs.tsx index 6302b378..856874a8 100644 --- a/src/components/editor-sidebar-tabs.tsx +++ b/src/components/editor-sidebar-tabs.tsx @@ -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 ( - - + + Fields Templates Generate @@ -33,9 +51,42 @@ export function TabsLine({ onFieldClick, onTemplateClick }: TabsLineProps) { - Generate coming soon +
+
+ + AI Form Generator +
+

+ 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. +

+