Skip to content

Commit 97e8d6b

Browse files
Merge pull request #48 from Nandgopal-R/feat/reports-and-editor-only
feat: implement Reports page with statistics and PDF export, and remo…
2 parents d97a1fb + 2ecd5f0 commit 97e8d6b

19 files changed

Lines changed: 1276 additions & 192 deletions

src/api/ai.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* AI API Layer
3+
*
4+
* Maps to the actual backend endpoints:
5+
* - POST /forms/ai-generate → AI form generation (creates form + fields server-side)
6+
* - POST /forms/:id/analytics → AI analytics report for a form
7+
*/
8+
9+
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
10+
11+
// ---- Helpers ----
12+
13+
async function handleResponse<T>(response: Response): Promise<T> {
14+
if (!response.ok) {
15+
const errorData = (await response.json().catch(() => ({}))) as {
16+
message?: string
17+
}
18+
throw new Error(
19+
errorData.message || `Request failed: ${response.statusText}`,
20+
)
21+
}
22+
const result = (await response.json()) as {
23+
success: boolean
24+
data: T
25+
message?: string
26+
}
27+
if (!result.success) {
28+
throw new Error(result.message || 'Request failed')
29+
}
30+
return result.data
31+
}
32+
33+
// ---- Exported API ----
34+
35+
export interface AIGeneratedField {
36+
id: string
37+
fieldName: string
38+
label: string
39+
fieldType: string
40+
fieldValueType: string
41+
validation?: Record<string, unknown>
42+
options?: Array<string>
43+
}
44+
45+
export interface AIGeneratedForm {
46+
id: string
47+
title: string
48+
description: string
49+
isPublished: boolean
50+
createdAt: string
51+
fields: Array<AIGeneratedField>
52+
}
53+
54+
export interface AnalyticsInsight {
55+
question: string
56+
metric: string
57+
value: string | number
58+
}
59+
60+
export interface AnalyticsTheme {
61+
theme: string
62+
description: string
63+
frequency: string
64+
}
65+
66+
export interface AnalyticsReport {
67+
totalResponsesAnalyzed: number
68+
executiveSummary: string
69+
quantitativeInsights: Array<AnalyticsInsight>
70+
qualitativeThemes: Array<AnalyticsTheme>
71+
}
72+
73+
export const aiApi = {
74+
/**
75+
* Generates a complete form (title + fields) from a text prompt.
76+
* The backend creates the form and all fields in a single transaction.
77+
* POST /forms/ai-generate
78+
* Body: { prompt: string }
79+
* Returns: { id, title, description, fields, ... }
80+
*/
81+
generateForm: async (prompt: string): Promise<AIGeneratedForm> => {
82+
const response = await fetch(`${API_URL}/forms/ai-generate`, {
83+
method: 'POST',
84+
headers: { 'Content-Type': 'application/json' },
85+
body: JSON.stringify({ prompt }),
86+
credentials: 'include',
87+
})
88+
return handleResponse<AIGeneratedForm>(response)
89+
},
90+
91+
/**
92+
* Generates an AI analytics report for a specific form's responses.
93+
* POST /forms/:formId/analytics
94+
* Body: {} (formId is in the URL)
95+
* Query: ?format=json (default)
96+
* Returns: AnalyticsReport
97+
*/
98+
generateSummary: async (formId: string): Promise<AnalyticsReport> => {
99+
const response = await fetch(`${API_URL}/forms/${formId}/analytics`, {
100+
method: 'POST',
101+
headers: { 'Content-Type': 'application/json' },
102+
body: JSON.stringify({}),
103+
credentials: 'include',
104+
})
105+
return handleResponse<AnalyticsReport>(response)
106+
},
107+
108+
/**
109+
* Placeholder — no backend endpoint exists yet.
110+
* Returns an empty array so AISuggestionPanel renders gracefully.
111+
*/
112+
suggestFields: (
113+
_fields: Array<string>,
114+
): Promise<{ suggestions: Array<{ label: string; type: string }> }> => {
115+
return Promise.resolve({ suggestions: [] })
116+
},
117+
}

src/api/forms.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,8 @@ async function handleResponse<T>(response: Response): Promise<T> {
117117
// Fallbacks ensure we always have some error to show
118118
throw new Error(
119119
errorData.message ||
120-
errorData.error ||
121-
`Request failed: ${response.statusText}`,
120+
errorData.error ||
121+
`Request failed: ${response.statusText}`,
122122
)
123123
}
124124

src/api/responses.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,9 @@ export const responsesApi = {
144144

145145
// GET /responses/received - Get all responses RECEIVED for forms owned by the user
146146
// Falls back to fetching per-form if endpoint doesn't exist on deployed backend
147-
getAllReceived: async (formIds?: Array<string>): Promise<Array<ReceivedResponse>> => {
147+
getAllReceived: async (
148+
formIds?: Array<string>,
149+
): Promise<Array<ReceivedResponse>> => {
148150
try {
149151
const response = await fetch(`${API_URL}/responses/received`, {
150152
method: 'GET',
Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,35 @@
1-
import { render, screen } from '@testing-library/react';
2-
import userEvent from '@testing-library/user-event';
3-
import { describe, expect, it, vi } from 'vitest';
4-
import { TabsLine } from './editor-sidebar-tabs';
1+
import { render, screen } from '@testing-library/react'
2+
import userEvent from '@testing-library/user-event'
3+
import { describe, expect, it, vi } from 'vitest'
4+
import { TabsLine } from './editor-sidebar-tabs'
55

66
describe('TabsLine', () => {
7-
it('renders the initial tab (Fields) correctly', () => {
8-
render(<TabsLine />);
9-
expect(screen.getByText('Short Text')).toBeInTheDocument();
10-
});
7+
const mockProps = {}
118

12-
it('switches tabs correctly', async () => {
13-
const user = userEvent.setup();
14-
render(<TabsLine />);
9+
it('renders the initial tab (Fields) correctly', () => {
10+
render(<TabsLine {...mockProps} />)
11+
expect(screen.getByText('Short Text')).toBeInTheDocument()
12+
})
1513

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

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

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

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

31-
expect(onFieldClick).toHaveBeenCalledWith('text');
32-
});
33-
});
30+
const textFieldButton = screen.getByText('Short Text')
31+
await user.click(textFieldButton)
32+
33+
expect(onFieldClick).toHaveBeenCalledWith('text')
34+
})
35+
})

src/components/editor-sidebar-tabs.tsx

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,35 @@
1+
import { useState } from 'react'
2+
import { Loader2, Sparkles } from 'lucide-react'
13
import { FieldItems } from './fields/field-items'
24
import { TemplateItems } from './fields/template-items'
35
import type { Template } from '@/api/templates'
46
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
7+
import { Button } from '@/components/ui/button'
58

69
interface TabsLineProps {
710
onFieldClick?: (fieldId: string) => void
811
onTemplateClick?: (template: Template) => void
12+
onAIGenerate?: (prompt: string) => void
13+
isAIGenerating?: boolean
914
}
1015

11-
export function TabsLine({ onFieldClick, onTemplateClick }: TabsLineProps) {
16+
export function TabsLine({
17+
onFieldClick,
18+
onTemplateClick,
19+
onAIGenerate,
20+
isAIGenerating,
21+
}: TabsLineProps) {
22+
const [prompt, setPrompt] = useState('')
23+
24+
const handleGenerate = () => {
25+
if (prompt.trim() && onAIGenerate) {
26+
onAIGenerate(prompt.trim())
27+
}
28+
}
29+
1230
return (
13-
<Tabs defaultValue="fields" className="w-full h-full">
14-
<TabsList variant="line" className="flex justify-center w-full">
31+
<Tabs defaultValue="fields" className="w-full h-full flex flex-col">
32+
<TabsList variant="line" className="flex justify-center w-full shrink-0">
1533
<TabsTrigger value="fields">Fields</TabsTrigger>
1634
<TabsTrigger value="templates">Templates</TabsTrigger>
1735
<TabsTrigger value="generate">Generate</TabsTrigger>
@@ -33,9 +51,42 @@ export function TabsLine({ onFieldClick, onTemplateClick }: TabsLineProps) {
3351

3452
<TabsContent
3553
value="generate"
36-
className="p-4 text-sm text-muted-foreground text-center"
54+
className="mt-0 flex-1 overflow-y-auto min-h-0 p-4"
3755
>
38-
Generate coming soon
56+
<div className="space-y-4">
57+
<div className="flex items-center gap-2 text-sm font-semibold">
58+
<Sparkles className="h-4 w-4 text-primary" />
59+
AI Form Generator
60+
</div>
61+
<p className="text-xs text-muted-foreground">
62+
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.
63+
</p>
64+
<textarea
65+
value={prompt}
66+
onChange={(e) => setPrompt(e.target.value)}
67+
placeholder="e.g. Create a job application form with fields for name, email, resume upload, work experience, and education..."
68+
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"
69+
rows={5}
70+
disabled={isAIGenerating}
71+
/>
72+
<Button
73+
onClick={handleGenerate}
74+
disabled={!prompt.trim() || isAIGenerating}
75+
className="w-full gap-2"
76+
>
77+
{isAIGenerating ? (
78+
<>
79+
<Loader2 className="h-4 w-4 animate-spin" />
80+
Generating...
81+
</>
82+
) : (
83+
<>
84+
<Sparkles className="h-4 w-4" />
85+
Generate Form
86+
</>
87+
)}
88+
</Button>
89+
</div>
3990
</TabsContent>
4091
</Tabs>
4192
)

src/components/field-sidebar.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,24 @@ import type { Template } from '@/api/templates'
44
interface FieldSidebarProps {
55
onFieldClick?: (fieldId: string) => void
66
onTemplateClick?: (template: Template) => void
7+
onAIGenerate?: (prompt: string) => void
8+
isAIGenerating?: boolean
79
}
810

911
export function FieldSidebar({
1012
onFieldClick,
1113
onTemplateClick,
14+
onAIGenerate,
15+
isAIGenerating,
1216
}: FieldSidebarProps) {
1317
return (
1418
<div className="h-full w-full flex flex-col">
15-
<TabsLine onFieldClick={onFieldClick} onTemplateClick={onTemplateClick} />
19+
<TabsLine
20+
onFieldClick={onFieldClick}
21+
onTemplateClick={onTemplateClick}
22+
onAIGenerate={onAIGenerate}
23+
isAIGenerating={isAIGenerating}
24+
/>
1625
</div>
1726
)
1827
}

0 commit comments

Comments
 (0)