Skip to content

Commit b8e3861

Browse files
authored
Merge pull request #46 from git-vish/development
Add form submission
2 parents ff121cd + fd10dfa commit b8e3861

File tree

16 files changed

+300
-107
lines changed

16 files changed

+300
-107
lines changed

backend/src/exceptions/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
class FormwiseError(Exception):
1212
"""Base exception for all formwise exceptions."""
1313

14-
def __init__(self, message: str = ""):
14+
def __init__(self, message: str | dict = ""):
1515
self.message = message
1616
super().__init__(self.message)
1717

backend/src/models/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from src.config import settings
44

5-
from .form import Form
5+
from .form import Form, FormResponse
66
from .user import User
77

88

@@ -14,7 +14,7 @@ class Config(BaseModel):
1414
max_responses: int = settings.MAX_RESPONSES
1515

1616

17-
DOCUMENT_MODELS = [User, Form]
17+
DOCUMENT_MODELS = [User, Form, FormResponse]
1818

1919
__all__ = [
2020
"Config",

backend/src/models/field.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,12 @@ class URLField(BaseField):
260260
type: Literal[FieldType.URL] = FieldType.URL
261261

262262
def validate_answer(self, answer: str) -> str:
263-
return AnyHttpUrl(answer).unicode_string()
263+
try:
264+
return AnyHttpUrl(answer).unicode_string()
265+
except ValueError as err:
266+
raise ValueError(
267+
"Answer must be a valid URL. Example: https://example.com"
268+
) from err
264269

265270

266271
# Type alias

backend/src/models/form.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from datetime import UTC, datetime
2-
from typing import TYPE_CHECKING, Annotated
2+
from typing import TYPE_CHECKING, Annotated, Any
3+
from uuid import uuid4
34

45
from beanie import Document, Link
56
from pydantic import BaseModel, Field, field_validator
@@ -67,6 +68,23 @@ class FormRead(BaseModel):
6768
created_at: datetime
6869

6970

71+
class FormSubmission(BaseModel):
72+
"""Request model for submitting a form."""
73+
74+
answers: dict[str, Any]
75+
76+
77+
class FormResponse(Document, FormSubmission):
78+
"""Database model for a form submission"""
79+
80+
class Settings:
81+
name = "responses"
82+
83+
id: Annotated[str, Field(default_factory=lambda: uuid4().hex)]
84+
form: Link[Form]
85+
created_at: Annotated[datetime, Field(default_factory=lambda: datetime.now(tz=UTC))]
86+
87+
7088
class FormOverview(BaseModel):
7189
"""Response model for a form (overview)."""
7290

@@ -77,7 +95,7 @@ class FormOverview(BaseModel):
7795
created_at: datetime
7896

7997
@staticmethod
80-
def from_forms(form_list: list[Form]) -> list["FormOverview"]:
98+
async def from_forms(form_list: list[Form]) -> list["FormOverview"]:
8199
"""Creates a list of FormOverview instances from a list of Form instances,
82100
sorted by creation date in descending order.
83101
@@ -91,8 +109,9 @@ def from_forms(form_list: list[Form]) -> list["FormOverview"]:
91109
return [
92110
FormOverview(
93111
**form.model_dump(),
94-
# TODO: Add response count
95-
response_count=0,
112+
response_count=await FormResponse.find(
113+
FormResponse.form.id == form.id
114+
).count(),
96115
)
97116
for form in form_list
98117
]

backend/src/routers/form.py

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
FormGenerate,
1515
FormOverview,
1616
FormRead,
17+
FormResponse,
18+
FormSubmission,
1719
)
1820
from src.models.user import User
1921
from src.utils.form_generation import FormGenerator
@@ -52,7 +54,7 @@ async def create_form_for_user(form: FormCreate, user: User) -> Form:
5254
new_form = Form(**form.model_dump(), creator=user)
5355
await new_form.create()
5456

55-
logger.info('Created Form: "%s" for User: %s', new_form.title, user)
57+
logger.info('Created Form: "%s" for User: %s', new_form.id, user)
5658
return new_form
5759

5860

@@ -100,7 +102,7 @@ async def get_form(form_id: str):
100102
"""Retrieves a form."""
101103
form = await Form.get(form_id, fetch_links=True)
102104
if not form:
103-
raise EntityNotFoundError("Form not found")
105+
raise EntityNotFoundError("Form not found.")
104106

105107
return FormRead(**form.model_dump())
106108

@@ -113,14 +115,17 @@ async def delete_form(form_id: str, user: CurrentUser):
113115
"""Deletes a form and its submissions (if owned by the user)."""
114116
form = await Form.get(form_id, fetch_links=True)
115117
if not form:
116-
raise EntityNotFoundError("Form not found")
118+
raise EntityNotFoundError("Form not found.")
117119

118120
if form.creator.id != user.id:
119121
raise ForbiddenError("Not authorized to delete this form.")
120122

123+
# Delete form responses
124+
await FormResponse.find(FormResponse.form.id == form.id).delete()
125+
# Delete form
121126
await form.delete()
122-
# TODO: Implement deletion of form submissions
123-
logger.info('Deleted Form: "%s" for User: %s', form.title, user)
127+
128+
logger.info('Deleted Form: "%s" for User: %s', form.id, user)
124129

125130

126131
@router.get(
@@ -130,4 +135,54 @@ async def delete_form(form_id: str, user: CurrentUser):
130135
)
131136
async def get_forms(user: CurrentUserWithLinks):
132137
"""Retrieves a list of user's forms."""
133-
return FormOverview.from_forms(user.forms)
138+
return await FormOverview.from_forms(user.forms)
139+
140+
141+
@router.post(
142+
"/{form_id}/submit",
143+
status_code=status.HTTP_200_OK,
144+
)
145+
async def submit_response(form_id: str, submission: FormSubmission):
146+
"""Submits a form response."""
147+
form = await Form.get(form_id)
148+
if not form:
149+
raise EntityNotFoundError("Form not found.")
150+
151+
if not form.is_active:
152+
raise ForbiddenError("Form is not active.")
153+
154+
# Validate submission
155+
invalid_fields = {}
156+
validated_answers = {}
157+
158+
for field in form.fields:
159+
answer = submission.answers.get(field.tag)
160+
161+
if not answer:
162+
if field.required:
163+
invalid_fields[field.tag] = "Field is required."
164+
else:
165+
validated_answers[field.tag] = None
166+
continue
167+
168+
try:
169+
validated_answers[field.tag] = field.validate_answer(answer)
170+
except ValueError as err:
171+
invalid_fields[field.tag] = str(err)
172+
173+
if invalid_fields:
174+
raise BadRequestError(invalid_fields)
175+
176+
# Save form response
177+
new_response = FormResponse(form=form, answers=validated_answers)
178+
await new_response.create()
179+
logger.info("Submitted response for form: %s", form.id)
180+
181+
# Check if form response limit has been reached
182+
response_count = await FormResponse.find(FormResponse.form.id == form.id).count()
183+
if response_count >= settings.MAX_RESPONSES:
184+
form.is_active = False
185+
await form.save()
186+
logger.info("Form response limit reached, disabling form: %s", form.id)
187+
188+
return {"detail": "Form response submitted successfully."}

frontend/app/(console)/forms/[formId]/page.tsx

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"use client";
22

3-
import FormResponses from "@/components/form/form-responses";
43
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
5-
import FormWise from "@/components/form/formwise";
64
import { useForm } from "@/hooks/use-forms";
75
import { useRouter } from "next/navigation";
6+
import FormWise from "@/components/form/formwise";
7+
import FormResponses from "@/components/form/form-responses";
88

99
interface FormPageProps {
1010
params: {
@@ -25,20 +25,26 @@ export default function FormPage({ params }: FormPageProps) {
2525

2626
return (
2727
<div className="container mx-auto p-4">
28-
<Tabs defaultValue="preview">
29-
<div className="flex justify-start md:justify-center">
30-
<TabsList>
31-
<TabsTrigger value="preview">Preview</TabsTrigger>
32-
<TabsTrigger value="responses">Responses</TabsTrigger>
33-
</TabsList>
34-
</div>
35-
<TabsContent value="preview">
36-
<FormWise form={form!} preview />
37-
</TabsContent>
38-
<TabsContent value="responses">
39-
<FormResponses formId={params.formId} />
40-
</TabsContent>
41-
</Tabs>
28+
<section className="flex justify-start md:justify-center mb-4">
29+
<h1 className="text-2xl font-bold">{form?.title}</h1>
30+
</section>
31+
32+
<main>
33+
<Tabs defaultValue="preview">
34+
<div className="flex justify-start md:justify-center">
35+
<TabsList>
36+
<TabsTrigger value="preview">Preview</TabsTrigger>
37+
<TabsTrigger value="responses">Responses</TabsTrigger>
38+
</TabsList>
39+
</div>
40+
<TabsContent value="preview">
41+
<FormWise form={form!} preview />
42+
</TabsContent>
43+
<TabsContent value="responses">
44+
<FormResponses formId={params.formId} />
45+
</TabsContent>
46+
</Tabs>
47+
</main>
4248
</div>
4349
);
4450
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"use client";
2+
3+
import { useForm } from "@/hooks/use-forms";
4+
import { useRouter } from "next/navigation";
5+
6+
import FormWise from "@/components/form/formwise";
7+
import Logo from "@/components/logo";
8+
import Loader from "@/components/layout/loader";
9+
10+
interface FormSubmissionPageProps {
11+
params: {
12+
formId: string;
13+
};
14+
}
15+
16+
export default function FormSubmissionPage({
17+
params,
18+
}: FormSubmissionPageProps) {
19+
const { data: form, isLoading, error } = useForm(params.formId);
20+
const router = useRouter();
21+
22+
if (isLoading) {
23+
return <Loader />;
24+
} else if (error) {
25+
router.push("/404");
26+
return null;
27+
}
28+
29+
return (
30+
<div className="container mx-auto px-4">
31+
<FormWise form={form!} />
32+
<div className="flex justify-center mt-8 mb-4">
33+
<Logo target_blank />
34+
</div>
35+
</div>
36+
);
37+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"use client";
2+
3+
import { useRouter } from "next/navigation";
4+
import { CheckIcon } from "lucide-react";
5+
import { Button } from "@/components/ui/button";
6+
import {
7+
Card,
8+
CardContent,
9+
CardFooter,
10+
CardHeader,
11+
CardTitle,
12+
} from "@/components/ui/card";
13+
14+
export default function SuccessPage() {
15+
const router = useRouter();
16+
17+
const handleSubmitAnother = () => {
18+
router.back();
19+
};
20+
21+
return (
22+
<div className="min-h-[calc(100vh-7rem)] flex items-center justify-center container mx-auto p-4">
23+
<Card className="w-full max-w-md text-center">
24+
<CardHeader className="flex flex-col items-center space-y-4">
25+
<CheckIcon className="text-primary" size={48} strokeWidth={2} />
26+
<CardTitle>Success</CardTitle>
27+
</CardHeader>
28+
<CardContent className="space-y-4">
29+
<p className="text-muted-foreground">
30+
Your response has been recorded.
31+
</p>
32+
</CardContent>
33+
<CardFooter>
34+
<Button
35+
variant="link"
36+
className="w-full"
37+
onClick={handleSubmitAnother}
38+
>
39+
Submit Another Response
40+
</Button>
41+
</CardFooter>
42+
</Card>
43+
</div>
44+
);
45+
}

frontend/components/dashboard/form-card.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
import { Button } from "@/components/ui/button";
1212
import {
1313
MoreVerticalIcon,
14-
TypeIcon,
1514
Share2Icon,
1615
Trash2Icon,
1716
ClockIcon,
@@ -82,9 +81,6 @@ export default function FormCard({ form, maxResponses }: FormCardProps) {
8281
<DropdownMenuItem onClick={handleShare}>
8382
<Share2Icon className="mr-2 h-4 w-4" /> Share
8483
</DropdownMenuItem>
85-
<DropdownMenuItem>
86-
<TypeIcon className="mr-2 h-4 w-4" /> Rename
87-
</DropdownMenuItem>
8884
<DropdownMenuSeparator />
8985
<DropdownMenuItem
9086
className="focus:text-destructive"

0 commit comments

Comments
 (0)