diff --git a/actions/form.actions.tsx b/actions/form.actions.tsx
new file mode 100644
index 00000000..30331269
--- /dev/null
+++ b/actions/form.actions.tsx
@@ -0,0 +1,318 @@
+'use server';
+
+import Ajv, { type ValidateFunction } from 'ajv';
+import AjvFormats from 'ajv-formats';
+import { and, arrayOverlaps, eq, or } from 'drizzle-orm';
+import FormInvalidResponse, {
+ FormInvalidResponseProps,
+} from '~/components/form/form-invalid-response';
+import FormSubmitPage, {
+ FormSubmitFormProps,
+} from '~/components/form/form-submit-page';
+import { ElementsType } from '~/components/form/interfaces/form-elements';
+import { getServerAuthSession } from '~/server/auth';
+import {
+ db,
+ formAnswers,
+ formSubmissions,
+ forms,
+ formsModifiableByPersons,
+ formsVisibleToPersons,
+} from '~/server/db';
+
+const ajv = new Ajv({
+ allErrors: true,
+ strict: false,
+ $data: true,
+});
+AjvFormats(ajv);
+
+class UserNotFoundErr extends Error {}
+
+const schemasCache: Record> = {};
+
+export async function getFormForSubmission(id: string): Promise<{
+ Element: React.ElementType;
+ props: FormInvalidResponseProps | Omit;
+}> {
+ try {
+ const session = await getServerAuthSession();
+ const form = await db.query.forms.findFirst({
+ columns: {
+ id: true,
+ title: true,
+ description: true,
+ isPublished: true,
+ isAnonymous: true,
+ isSingleResponse: true,
+ isEditingAllowed: true,
+ isActive: true,
+ isShuffled: true,
+ expiryDate: true,
+ onSubmitMessage: true,
+ persistentUrl: true,
+ requiredQuestions: true,
+ questionValidations: true,
+ },
+ with: {
+ questions: true,
+ },
+ where: (forms, { or, exists, eq }) =>
+ and(
+ session?.person.id
+ ? or(
+ eq(forms.isAnonymous, true),
+ exists(
+ db
+ .select()
+ .from(formsModifiableByPersons)
+ .where(
+ and(
+ eq(
+ formsModifiableByPersons.personId,
+ session.person.id
+ ),
+ eq(formsModifiableByPersons.formId, forms.id)
+ )
+ )
+ )
+ )
+ : eq(forms.isAnonymous, true),
+ or(
+ eq(forms.id, Number(id)),
+ eq(forms.persistentUrl, id),
+ arrayOverlaps(forms.oldPersistentUrls, [id])
+ )
+ ),
+ });
+ if (!form || !form.isPublished)
+ return {
+ Element: FormInvalidResponse,
+ props: {
+ type: 'FormNotFound',
+ },
+ };
+
+ if (!(session?.person || form.isAnonymous)) throw new UserNotFoundErr();
+
+ if (!form.isActive)
+ return {
+ Element: FormInvalidResponse,
+ props: {
+ type: 'FormExpired',
+ },
+ };
+
+ if (form.expiryDate && form.expiryDate < new Date()) {
+ await db.update(forms).set({ isActive: false });
+
+ return {
+ Element: FormInvalidResponse,
+ props: {
+ type: 'FormExpired',
+ },
+ };
+ }
+
+ const submission =
+ !form.isAnonymous && form.isSingleResponse
+ ? await db.query.formSubmissions.findFirst({
+ with: {
+ answers: true,
+ },
+ where: (formSubmission, { eq }) =>
+ and(
+ eq(formSubmission.formId, form.id),
+ eq(formSubmission.email, session!.user.email)
+ ),
+ })
+ : undefined;
+
+ if (submission && !form.isEditingAllowed)
+ return {
+ Element: FormInvalidResponse,
+ props: {
+ type: 'FormEditNotAllowed',
+ },
+ };
+ const questionElements: FormSubmitFormProps['questions'] = [];
+
+ if (form.isShuffled) {
+ form.questions = form.questions.sort(() => Math.random() - 0.5);
+ } else {
+ form.questions = form.questions.sort(
+ (a, b) => a.pageNumber - b.pageNumber
+ );
+ }
+
+ form.questions.forEach((question) => {
+ question.pageNumber = ~~question.pageNumber;
+ if (!questionElements[question.pageNumber]) {
+ questionElements[question.pageNumber] = [];
+ }
+ questionElements[question.pageNumber].push({
+ question: question.question,
+ id: question.id.toString(),
+ description: question.description ?? undefined,
+ isRequired: question.isRequired,
+ items: question.choices ?? [],
+ range: question.range ?? [],
+ mimeTypes: question.mimeTypes ?? [],
+ marks: question.marks,
+ inputType: question.inputType as ElementsType,
+ });
+ });
+ const answers = submission?.answers.reduce(
+ (acc: Record, answer) => {
+ acc[answer.questionId] = answer.value as string | number | string[];
+ return acc;
+ },
+ {}
+ );
+
+ const pages = questionElements.length;
+ return {
+ Element: FormSubmitPage,
+ props: {
+ form: {
+ id: form.id,
+ url: form.persistentUrl,
+ title: form.title,
+ description: form.description ?? undefined,
+ onSubmitMessage: form.onSubmitMessage,
+ pages: pages,
+ },
+ questions: questionElements,
+ requiredQuestions: form.requiredQuestions,
+ questionValidations: form.questionValidations!,
+ answers: answers,
+ },
+ };
+ } catch (error) {
+ console.error('Error getting form for submission:', error);
+ throw new Error('Failed to get form for submission');
+ }
+}
+
+export async function submitForm(
+ id: number,
+ formData: Record
+) {
+ try {
+ const session = await getServerAuthSession();
+
+ const [form] = await db
+ .select({
+ id: forms.id,
+ title: forms.title,
+ description: forms.description,
+ isPublished: forms.isPublished,
+ isActive: forms.isActive,
+ questionValidations: forms.questionValidations,
+ requiredQuestions: forms.requiredQuestions,
+ isAnonymous: forms.isAnonymous,
+ expiryDate: forms.expiryDate,
+ isSingleResponse: forms.isSingleResponse,
+ isEditingAllowed: forms.isEditingAllowed,
+ onSubmitMessage: forms.onSubmitMessage,
+ })
+ .from(forms)
+ .innerJoin(
+ formsVisibleToPersons,
+ eq(forms.id, formsVisibleToPersons.formId)
+ )
+ .limit(1)
+ .where(
+ and(
+ eq(forms.id, id),
+ or(
+ eq(forms.isAnonymous, true),
+ session?.person &&
+ eq(formsVisibleToPersons.personId, session.person.id)
+ )
+ )
+ );
+ if (!form || !form.isPublished)
+ return { title: 'Error', description: 'Form not found' };
+
+ if (!schemasCache[form.id]) {
+ schemasCache[form.id] = ajv.compile({
+ type: 'object',
+ properties: form.questionValidations,
+ additionalProperties: false,
+ required: form.requiredQuestions,
+ });
+ }
+
+ const validate = schemasCache[form.id];
+
+ if (!validate(formData))
+ return { title: 'Error', description: 'Invalid form data' };
+
+ if (!session?.user && !form.isAnonymous) throw new UserNotFoundErr();
+
+ if (!form.isActive)
+ return { title: 'Error', description: 'Form is expired' };
+
+ if (form.expiryDate && form.expiryDate < new Date()) {
+ await db.update(forms).set({ isActive: true }).where(eq(forms.id, id));
+ return { title: 'Error', description: 'Form is expired' };
+ }
+ await db.transaction(async (tx) => {
+ if (form.isSingleResponse && !form.isAnonymous) {
+ const response = await db.query.formSubmissions.findFirst({
+ where: (formSubmission, { eq }) =>
+ and(
+ eq(formSubmission.formId, id),
+ eq(formSubmission.email, session!.user.email)
+ ),
+ });
+
+ if (response) {
+ if (!form.isEditingAllowed)
+ return { title: 'Error', description: 'Form is single response' };
+ else {
+ console.log('deleting response', response.id);
+ await tx
+ .delete(formSubmissions)
+ .where(eq(formSubmissions.id, response.id));
+ // return { title: 'Success', description: form.onSubmitMessage };
+ }
+ }
+ }
+
+ const [submission] = await tx
+ .insert(formSubmissions)
+ .values({
+ formId: id,
+ email: form.isAnonymous ? '' : session!.user.email,
+ })
+ .returning({ id: formSubmissions.id });
+
+ await tx.insert(formAnswers).values(
+ Object.entries(formData).reduce(
+ (
+ acc: {
+ submissionId: number;
+ questionId: number;
+ value: string | number | string[];
+ }[],
+ [questionId, value]
+ ) => {
+ acc.push({
+ submissionId: submission.id,
+ questionId: Number(questionId),
+ value,
+ });
+ return acc;
+ },
+ []
+ )
+ );
+ });
+ return { title: 'Success', description: form.onSubmitMessage };
+ } catch (error) {
+ console.error('Error submitting form:', error);
+ throw new Error('Failed to submit form');
+ }
+}
diff --git a/app/[locale]/forms/[id]/page.tsx b/app/[locale]/forms/[id]/page.tsx
index 5021f0c4..9360ba27 100644
--- a/app/[locale]/forms/[id]/page.tsx
+++ b/app/[locale]/forms/[id]/page.tsx
@@ -1,13 +1,15 @@
-import WorkInProgress from '~/components/work-in-progress';
-
-// FIXME: This will contain both ids from forms and persistent URLs.
-// Old persistent URLs should trigger a redirect.
// export async function generateStaticParams() {}
-export default function Form({
- params: { locale },
+import { redirect } from 'next/navigation';
+
+import { getFormForSubmission } from '~/actions/form.actions';
+
+export default async function Form({
+ params: { locale, id },
}: {
params: { locale: string; id: string };
}) {
- return ;
+ if (!id) return redirect('/');
+ const { Element, props } = await getFormForSubmission(id);
+ return ;
}
diff --git a/components/form/fields/checkbox-field-element.tsx b/components/form/fields/checkbox-field-element.tsx
new file mode 100644
index 00000000..d8a26b91
--- /dev/null
+++ b/components/form/fields/checkbox-field-element.tsx
@@ -0,0 +1,29 @@
+import { forwardRef } from 'react';
+
+import type {
+ ElementsType,
+ FormElement,
+} from '~/components/form/interfaces/form-elements';
+import { Input, type InputProps } from '~/components/inputs';
+
+const inputType: ElementsType = 'CheckBoxField';
+
+export const CheckBoxFieldFormElement: FormElement = {
+ inputType,
+ // eslint-disable-next-line react/display-name
+ formComponent: forwardRef(
+ ({ onChange, value, ...restProps }, ref) => (
+ {
+ onChange?.({
+ target: { value: event.target.checked },
+ } as unknown as React.ChangeEvent);
+ }}
+ defaultChecked={value as unknown as boolean}
+ />
+ )
+ ),
+};
diff --git a/components/form/fields/date-field-element.tsx b/components/form/fields/date-field-element.tsx
new file mode 100644
index 00000000..839731ac
--- /dev/null
+++ b/components/form/fields/date-field-element.tsx
@@ -0,0 +1,19 @@
+import { forwardRef } from 'react';
+
+import type {
+ ElementsType,
+ FormElement,
+} from '~/components/form/interfaces/form-elements';
+import { Input, type InputProps } from '~/components/inputs';
+
+const inputType: ElementsType = 'DateField';
+
+export const DateFieldFormElement: FormElement = {
+ inputType,
+ // eslint-disable-next-line react/display-name
+ formComponent: forwardRef(
+ ({ ...restProps }, ref) => (
+
+ )
+ ),
+};
diff --git a/components/form/fields/date-time-field-element.tsx b/components/form/fields/date-time-field-element.tsx
new file mode 100644
index 00000000..4472dff3
--- /dev/null
+++ b/components/form/fields/date-time-field-element.tsx
@@ -0,0 +1,30 @@
+import { forwardRef } from 'react';
+
+import type {
+ ElementsType,
+ FormElement,
+} from '~/components/form/interfaces/form-elements';
+import { Input, type InputProps } from '~/components/inputs';
+
+const inputType: ElementsType = 'DateTimeField';
+
+export const DateTimeFieldFormElement: FormElement = {
+ inputType,
+ // eslint-disable-next-line react/display-name
+ formComponent: forwardRef(
+ ({ onChange, value, ...restProps }, ref) => (
+ {
+ onChange?.({
+ target: { value: event.target.value + ':00Z' },
+ } as React.ChangeEvent);
+ }}
+ />
+ )
+ ),
+};
diff --git a/components/form/fields/email-field-element.tsx b/components/form/fields/email-field-element.tsx
new file mode 100644
index 00000000..9d225408
--- /dev/null
+++ b/components/form/fields/email-field-element.tsx
@@ -0,0 +1,17 @@
+import { forwardRef } from 'react';
+
+import type {
+ ElementsType,
+ FormElement,
+} from '~/components/form/interfaces/form-elements';
+import { Input, type InputProps } from '~/components/inputs';
+
+const inputType: ElementsType = 'EmailField';
+
+export const EmailFieldFormElement: FormElement = {
+ inputType,
+ // eslint-disable-next-line react/display-name
+ formComponent: forwardRef(
+ ({ ...restProps }, ref) =>
+ ),
+};
diff --git a/components/form/fields/index.ts b/components/form/fields/index.ts
new file mode 100644
index 00000000..94b3c99d
--- /dev/null
+++ b/components/form/fields/index.ts
@@ -0,0 +1,12 @@
+export * from './checkbox-field-element';
+export * from './date-field-element';
+export * from './date-time-field-element';
+export * from './email-field-element';
+export * from './multi-select-element';
+export * from './number-field-element';
+export * from './phone-field-element';
+export * from './radio-field-element';
+export * from './select-field-element';
+export * from './textarea-element';
+export * from './text-field-element';
+export * from './time-field-element';
diff --git a/components/form/fields/multi-select-element.tsx b/components/form/fields/multi-select-element.tsx
new file mode 100644
index 00000000..394196fe
--- /dev/null
+++ b/components/form/fields/multi-select-element.tsx
@@ -0,0 +1,62 @@
+import { forwardRef } from 'react';
+
+import {
+ MultipleSelector,
+ type MultipleSelectorProps,
+ type MultipleSelectorRef,
+} from '~/components/inputs';
+import { Label } from '~/components/ui';
+
+import type { ElementsType, FormElement } from '../interfaces/form-elements';
+
+const inputType: ElementsType = 'MultiSelectField';
+
+export interface MultiSelectGenericProps extends MultipleSelectorProps {
+ items: string[];
+ label?: string;
+ inputClassName?: string;
+ description?: string;
+ name: string;
+ required?: boolean;
+}
+
+const MultiSelectDropdown = forwardRef<
+ MultipleSelectorRef,
+ MultiSelectGenericProps
+>(({ className, description, label, inputClassName, items, ...props }, ref) => {
+ return (
+
+ }
+ {...props}
+ />
+
+ );
+});
+
+MultiSelectDropdown.displayName = 'MultiSelectDropdown';
+
+export const MultiSelectFormElement: FormElement = {
+ inputType,
+ formComponent: MultiSelectDropdown,
+};
diff --git a/components/form/fields/number-field-element.tsx b/components/form/fields/number-field-element.tsx
new file mode 100644
index 00000000..c7acb88c
--- /dev/null
+++ b/components/form/fields/number-field-element.tsx
@@ -0,0 +1,38 @@
+import { forwardRef } from 'react';
+
+import type {
+ ElementsType,
+ FormElement,
+} from '~/components/form/interfaces/form-elements';
+import { Input, type InputProps } from '~/components/inputs';
+
+const inputType: ElementsType = 'NumberField';
+
+export const NumberFieldFormElement: FormElement = {
+ inputType,
+ // eslint-disable-next-line react/display-name
+ formComponent: forwardRef(
+ ({ onChange, value, ...restProps }, ref) => {
+ const handleInputChange = (
+ event: React.ChangeEvent
+ ) => {
+ const inputValue = Number.isNaN(parseFloat(event.target.value))
+ ? event.target.value
+ : parseFloat(event.target.value);
+ onChange?.(
+ inputValue as unknown as React.ChangeEvent
+ );
+ };
+
+ return (
+
+ );
+ }
+ ),
+};
diff --git a/components/form/fields/phone-field-element.tsx b/components/form/fields/phone-field-element.tsx
new file mode 100644
index 00000000..ae01a6a1
--- /dev/null
+++ b/components/form/fields/phone-field-element.tsx
@@ -0,0 +1,36 @@
+import { forwardRef } from 'react';
+
+import type {
+ ElementsType,
+ FormElement,
+} from '~/components/form/interfaces/form-elements';
+import { Input, type InputProps } from '~/components/inputs';
+
+const inputType: ElementsType = 'PhoneField';
+
+export const PhoneFieldFormElement: FormElement = {
+ inputType,
+ // eslint-disable-next-line react/display-name
+ formComponent: forwardRef(
+ ({ onChange, ...props }, ref) => {
+ const handleInputChange = (
+ event: React.ChangeEvent
+ ) => {
+ const inputValue = event.target.value.replace(/\D/g, '');
+ const truncatedValue = inputValue.slice(0, 10);
+ event.target.value = truncatedValue;
+ onChange?.(event);
+ };
+
+ return (
+
+ );
+ }
+ ),
+};
diff --git a/components/form/fields/radio-field-element.tsx b/components/form/fields/radio-field-element.tsx
new file mode 100644
index 00000000..ee78f141
--- /dev/null
+++ b/components/form/fields/radio-field-element.tsx
@@ -0,0 +1,65 @@
+import { forwardRef } from 'react';
+import type * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
+
+import { Label, RadioGroup, RadioGroupItem } from '~/components/ui';
+
+import type { ElementsType, FormElement } from '../interfaces/form-elements';
+
+export interface RadioGenericProps
+ extends React.ComponentPropsWithoutRef {
+ items: string[];
+ label?: string;
+ inputClassName?: string;
+ description?: string;
+}
+
+const inputType: ElementsType = 'RadioField';
+
+const RadioGeneric = forwardRef<
+ React.ElementRef,
+ RadioGenericProps
+>(({ className, description, label, inputClassName, items, ...props }, ref) => {
+ return (
+
+ );
+});
+
+RadioGeneric.displayName = 'RadioGeneric';
+
+export const RadioGenericFormElement: FormElement = {
+ inputType,
+ formComponent: RadioGeneric,
+};
diff --git a/components/form/fields/select-field-element.tsx b/components/form/fields/select-field-element.tsx
new file mode 100644
index 00000000..6bcffcc7
--- /dev/null
+++ b/components/form/fields/select-field-element.tsx
@@ -0,0 +1,77 @@
+import type * as SelectPrimitive from '@radix-ui/react-select';
+import {
+ type ComponentPropsWithoutRef,
+ type ElementRef,
+ forwardRef,
+} from 'react';
+
+import { Label } from '~/components/ui';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '~/components/inputs';
+
+import type { ElementsType, FormElement } from '../interfaces/form-elements';
+
+const inputType: ElementsType = 'SelectDropdown';
+
+export interface SelectGenericProps
+ extends ComponentPropsWithoutRef {
+ variant?: 'form' | 'ui';
+ items: string[];
+ inputClassName?: string;
+ className?: string;
+ onChange?: (value: string) => void;
+ description?: string;
+ label?: string;
+}
+
+const SelectDropdown = forwardRef<
+ ElementRef,
+ SelectGenericProps
+>(({ className, description, inputClassName, items, label, ...props }, ref) => {
+ return (
+
+ );
+});
+
+SelectDropdown.displayName = 'SelectDropdown';
+
+export const SelectDropdownFormElement: FormElement = {
+ inputType,
+ formComponent: SelectDropdown,
+};
diff --git a/components/form/fields/text-field-element.tsx b/components/form/fields/text-field-element.tsx
new file mode 100644
index 00000000..17ab8bcd
--- /dev/null
+++ b/components/form/fields/text-field-element.tsx
@@ -0,0 +1,19 @@
+import { forwardRef } from 'react';
+
+import type {
+ ElementsType,
+ FormElement,
+} from '~/components/form/interfaces/form-elements';
+import { Input, type InputProps } from '~/components/inputs';
+
+//import TextValidationForm from './InputBasedForm';
+
+const inputType: ElementsType = 'TextField';
+
+export const TextFieldFormElement: FormElement = {
+ inputType,
+ // eslint-disable-next-line react/display-name
+ formComponent: forwardRef(
+ ({ ...restProps }, ref) =>
+ ),
+};
diff --git a/components/form/fields/textarea-element.tsx b/components/form/fields/textarea-element.tsx
new file mode 100644
index 00000000..5627f7cf
--- /dev/null
+++ b/components/form/fields/textarea-element.tsx
@@ -0,0 +1,61 @@
+import { forwardRef } from 'react';
+
+import type {
+ ElementsType,
+ FormElement,
+} from '~/components/form/interfaces/form-elements';
+import { Textarea, type TextareaProps } from '~/components/inputs';
+import { Label } from '~/components/ui';
+
+const inputType: ElementsType = 'TextAreaField';
+
+export interface TextAreaGenericProps extends TextareaProps {
+ label?: string;
+ description?: string;
+ inputClassName?: string;
+}
+
+const TextAreaGeneric = forwardRef(
+ (
+ {
+ className,
+ description,
+ inputClassName,
+ placeholder = 'Enter text',
+ label = 'TextArea',
+ ...props
+ },
+ ref
+ ) => {
+ return (
+
+ {label && (
+
+ )}
+ {description && (
+
{description}
+ )}
+
+
+ );
+ }
+);
+
+TextAreaGeneric.displayName = 'TextAreaGeneric';
+
+export const TextAreaFieldFormElement: FormElement = {
+ inputType,
+ formComponent: TextAreaGeneric,
+};
diff --git a/components/form/fields/time-field-element.tsx b/components/form/fields/time-field-element.tsx
new file mode 100644
index 00000000..c559d7b9
--- /dev/null
+++ b/components/form/fields/time-field-element.tsx
@@ -0,0 +1,19 @@
+import { forwardRef } from 'react';
+
+import type {
+ ElementsType,
+ FormElement,
+} from '~/components/form/interfaces/form-elements';
+import { Input, type InputProps } from '~/components/inputs';
+
+const inputType: ElementsType = 'TimeField';
+
+export const TimeFieldFormElement: FormElement = {
+ inputType,
+ // eslint-disable-next-line react/display-name
+ formComponent: forwardRef(
+ ({ ...restProps }, ref) => {
+ return ;
+ }
+ ),
+};
diff --git a/components/form/form-invalid-response.tsx b/components/form/form-invalid-response.tsx
new file mode 100644
index 00000000..b3be8593
--- /dev/null
+++ b/components/form/form-invalid-response.tsx
@@ -0,0 +1,18 @@
+import { getTranslations } from '~/i18n/translations';
+
+export interface FormInvalidResponseProps {
+ type: 'FormNotFound' | 'FormExpired' | 'FormEditNotAllowed';
+}
+export default async function FormInvalidResponse({
+ locale,
+ type,
+}: FormInvalidResponseProps & { locale: string }) {
+ const response = (await getTranslations(locale)).Forms;
+ const text = response[type];
+ return (
+
+ {text.title}
+ {text.content}
+
+ );
+}
diff --git a/components/form/form-submit-form.tsx b/components/form/form-submit-form.tsx
new file mode 100644
index 00000000..d7eca918
--- /dev/null
+++ b/components/form/form-submit-form.tsx
@@ -0,0 +1,155 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import { useRouter } from 'next/navigation';
+import { useEffect, useState } from 'react';
+import { useForm } from 'react-hook-form';
+
+import { submitForm } from '~/actions/form.actions';
+import { Button } from '~/components/ui';
+import { toast } from '~/lib/hooks';
+import { validateResolver } from '~/lib/validateResolver';
+
+import { Form, FormControl, FormField, FormItem, FormMessage } from './form';
+import type { FormSubmitFormProps } from './form-submit-page';
+import {
+ FormElements,
+ type validationProperty,
+} from './interfaces/form-elements';
+
+export default function FormSubmitForm({
+ form,
+ locale,
+ id,
+ questions,
+ requiredQuestions,
+ questionValidations,
+ answers,
+}: FormSubmitFormProps) {
+ const Router = useRouter();
+ useEffect(() => {
+ if (form.id.toString() != id && form.url != id)
+ window.history.replaceState(
+ {},
+ '',
+ `/${locale}/forms/${form.url ?? form.id}`
+ );
+ }, []);
+ const [previousStep, setPreviousStep] = useState(0);
+ const [currentStep, setCurrentStep] = useState(0);
+ const delta = currentStep - previousStep;
+
+ const jsonSchema: {
+ type: string;
+ properties: Record;
+ required: string[];
+ additionalProperties: boolean;
+ } = {
+ type: 'object',
+ properties: questionValidations,
+ required: requiredQuestions,
+ additionalProperties: false,
+ };
+ const resolver = (data: FormData, context: string | undefined) =>
+ validateResolver(data, context, jsonSchema);
+ const forms = useForm({
+ context: form.id.toString(),
+ resolver: resolver,
+ defaultValues: answers,
+ mode: 'onChange',
+ });
+
+ const next = async () => {
+ const fields = questions[currentStep].map((question) => question.id);
+
+ // types cannot be inferred during compile time
+ const output = await forms.trigger(fields as never, {
+ shouldFocus: true,
+ });
+
+ if (!output) return;
+ if (currentStep < form.pages - 1) {
+ setPreviousStep(currentStep);
+ setCurrentStep((step) => step + 1);
+ } else {
+ await forms.handleSubmit(onSubmit)();
+ }
+ };
+
+ const prev = () => {
+ if (currentStep > 0) {
+ setPreviousStep(currentStep);
+ setCurrentStep((step) => step - 1);
+ }
+ };
+ const onSubmit = async (values: FormData) => {
+ const output = await forms.trigger();
+ if (!output) return;
+
+ const result = await submitForm(
+ form.id,
+ values as unknown as Record
+ );
+ toast(result);
+ Router.push(`/${locale}/forms`);
+ };
+
+ return (
+
+
+ );
+}
diff --git a/components/form/form-submit-page.tsx b/components/form/form-submit-page.tsx
new file mode 100644
index 00000000..a610bf21
--- /dev/null
+++ b/components/form/form-submit-page.tsx
@@ -0,0 +1,82 @@
+import { getTranslations } from '~/i18n/translations';
+
+import FormSubmitForm from './form-submit-form';
+import {
+ type ElementsType,
+ type validationProperty,
+} from './interfaces/form-elements';
+
+export interface FormSubmitFormProps {
+ form: {
+ id: number;
+ url: string | null;
+ title: string;
+ description: string | undefined;
+ onSubmitMessage: string;
+ pages: number;
+ };
+ id: string;
+ questions: {
+ id: string;
+ question: string;
+ description?: string | undefined;
+ isRequired: boolean;
+ inputType: ElementsType;
+ items?: string[] | undefined;
+ mimeTypes?: string[] | undefined;
+ range?: string[] | undefined;
+ marks: number;
+ }[][];
+ requiredQuestions: string[];
+ questionValidations: Record;
+ answers?: Record;
+ locale: string;
+}
+export default function FormSubmitPage({
+ locale,
+ form,
+ id,
+ questions,
+ requiredQuestions,
+ questionValidations,
+ answers,
+}: FormSubmitFormProps) {
+ return (
+
+
+
+
+
+ );
+}
+async function FormDetails({
+ title,
+ description,
+ locale,
+}: {
+ title: string;
+ description?: string;
+ locale: string;
+}) {
+ const text = (await getTranslations(locale)).FormDetails;
+ return (
+ <>
+ {text.title}
+ {title}
+ {text.description}
+ {description}
+ >
+ );
+}
diff --git a/components/form/form.tsx b/components/form/form.tsx
new file mode 100644
index 00000000..ee514c08
--- /dev/null
+++ b/components/form/form.tsx
@@ -0,0 +1,180 @@
+import * as React from 'react';
+import type * as LabelPrimitive from '@radix-ui/react-label';
+import { Slot } from '@radix-ui/react-slot';
+import {
+ Controller,
+ type ControllerProps,
+ type FieldPath,
+ type FieldValues,
+ FormProvider,
+ useFormContext,
+} from 'react-hook-form';
+
+import { cn } from '~/lib/utils';
+import { Label } from '~/components/ui/label';
+
+const Form = FormProvider;
+
+interface FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+> {
+ name: TName;
+}
+
+const FormFieldContext = React.createContext(
+ {} as FormFieldContextValue
+);
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ );
+};
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext);
+ const itemContext = React.useContext(FormItemContext);
+ const { getFieldState, formState } = useFormContext();
+
+ const fieldState = getFieldState(fieldContext.name, formState);
+
+ if (!fieldContext) {
+ throw new Error('useFormField should be used within ');
+ }
+
+ const { id } = itemContext;
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ };
+};
+
+interface FormItemContextValue {
+ id: string;
+}
+
+const FormItemContext = React.createContext(
+ {} as FormItemContextValue
+);
+
+const FormItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const id = React.useId();
+
+ return (
+
+
+
+ );
+});
+FormItem.displayName = 'FormItem';
+
+const FormLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ disabled: boolean;
+ required: boolean;
+ }
+>(({ className, ...props }, ref) => {
+ const { error, formItemId } = useFormField();
+
+ return (
+
+ );
+});
+FormLabel.displayName = 'FormLabel';
+
+const FormControl = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => {
+ const { error, formItemId, formDescriptionId, formMessageId } =
+ useFormField();
+
+ return (
+
+ );
+});
+FormControl.displayName = 'FormControl';
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { formDescriptionId } = useFormField();
+
+ return (
+
+ );
+});
+FormDescription.displayName = 'FormDescription';
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, children, ...props }, ref) => {
+ const { error, formMessageId } = useFormField();
+ const body = error ? String(error?.message) : children;
+
+ if (!body) {
+ return null;
+ }
+
+ return (
+
+ {body}
+
+ );
+});
+FormMessage.displayName = 'FormMessage';
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+};
diff --git a/components/form/interfaces/form-elements.tsx b/components/form/interfaces/form-elements.tsx
new file mode 100644
index 00000000..5d5f50b0
--- /dev/null
+++ b/components/form/interfaces/form-elements.tsx
@@ -0,0 +1,89 @@
+import type { ForwardRefExoticComponent, RefAttributes } from 'react';
+
+import { type InputProps, type MultipleSelectorRef } from '~/components/inputs';
+
+import {
+ CheckBoxFieldFormElement,
+ DateFieldFormElement,
+ DateTimeFieldFormElement,
+ EmailFieldFormElement,
+ MultiSelectFormElement,
+ type MultiSelectGenericProps,
+ NumberFieldFormElement,
+ PhoneFieldFormElement,
+ RadioGenericFormElement,
+ type RadioGenericProps,
+ SelectDropdownFormElement,
+ type SelectGenericProps,
+ TextAreaFieldFormElement,
+ type TextAreaGenericProps,
+ TextFieldFormElement,
+ TimeFieldFormElement,
+} from '../fields';
+
+export type ElementsType =
+ | 'TextField'
+ | 'EmailField'
+ | 'SelectDropdown'
+ | 'TimeField'
+ | 'PhoneField'
+ | 'DateField'
+ | 'RadioField'
+ | 'DateTimeField'
+ | 'NumberField'
+ | 'TextAreaField'
+ | 'MultiSelectField'
+ | 'CheckBoxField';
+
+export interface validationProperty {
+ type: string;
+ format?: string;
+ formatMinimum?: string;
+ formatMaximum?: string;
+}
+
+export interface GenericProps {
+ label?: string;
+ description?: string;
+ errorMsg?: string;
+ inputClassName?: string;
+ className?: string;
+}
+
+// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
+export type FormElement = {
+ inputType: ElementsType;
+ formComponent:
+ | ForwardRefExoticComponent>
+ | ForwardRefExoticComponent<
+ MultiSelectGenericProps & RefAttributes
+ >
+ | ForwardRefExoticComponent<
+ RadioGenericProps & RefAttributes
+ >
+ | ForwardRefExoticComponent<
+ SelectGenericProps & RefAttributes
+ >
+ | ForwardRefExoticComponent<
+ TextAreaGenericProps & RefAttributes
+ >;
+};
+
+type FormElementsType = {
+ [Key in ElementsType]: FormElement;
+};
+
+export const FormElements: FormElementsType = {
+ TextField: TextFieldFormElement,
+ EmailField: EmailFieldFormElement,
+ SelectDropdown: SelectDropdownFormElement,
+ TimeField: TimeFieldFormElement,
+ PhoneField: PhoneFieldFormElement,
+ DateField: DateFieldFormElement,
+ RadioField: RadioGenericFormElement,
+ DateTimeField: DateTimeFieldFormElement,
+ NumberField: NumberFieldFormElement,
+ TextAreaField: TextAreaFieldFormElement,
+ MultiSelectField: MultiSelectFormElement,
+ CheckBoxField: CheckBoxFieldFormElement,
+};
diff --git a/i18n/en.ts b/i18n/en.ts
index 6b0ef29e..7474cf97 100644
--- a/i18n/en.ts
+++ b/i18n/en.ts
@@ -89,7 +89,31 @@ const text: Translations = {
copyright:
'© 2024 National Institute of Technology Kurukshetra. All Rights Reserved.',
},
- Forms: { title: 'FORMS' },
+ Forms: {
+ title: 'FORMS',
+ FormNotFound: {
+ title: 'Form Not Found',
+ content:
+ 'Sorry, we could not find the form you were looking for or you are not authorised to access it.',
+ },
+ FormExpired: {
+ title: 'Form Expired',
+ content: 'The form you are looking for has expired.',
+ },
+ FormEditNotAllowed: {
+ title: 'Form Edit Not Allowed',
+ content:
+ 'Sorry, you are not allowed to edit this form after submitting it.',
+ },
+ FormDetails: {
+ title: 'Form Title',
+ description: 'Form Description',
+ },
+ },
+ FormDetails: {
+ title: 'Form Title',
+ description: 'Form Description',
+ },
Header: {
institute: 'Institute',
academics: 'Academics',
diff --git a/i18n/hi.ts b/i18n/hi.ts
index fa42a029..eef80dfc 100644
--- a/i18n/hi.ts
+++ b/i18n/hi.ts
@@ -85,7 +85,32 @@ const text: Translations = {
copyright:
'© २०२४ राष्ट्रीय प्रौद्योगिकी संस्थान कुरूक्षेत्र। सर्वाधिकार आरक्षित।',
},
- Forms: { title: 'फॉर्म्स' },
+ Forms: {
+ title: 'फॉर्म्स',
+ FormNotFound: {
+ title: 'फ़ॉर्म नहीं मिला',
+ content:
+ 'माफ़ कीजिए, हम वह फ़ॉर्म नहीं ढूंढ पा रहे हैं जिसे आप ढूंढ रहे थे या आपके पास इसे एक्सेस करने की अनुमति नहीं है।',
+ },
+ FormExpired: {
+ title: 'फ़ॉर्म की समाप्ति',
+ content:
+ 'आप जिस फ़ॉर्म को ढूंढ रहे हैं, उसको जमा करने की समय सीमा समाप्त हो चुकी है।',
+ },
+ FormEditNotAllowed: {
+ title: 'फ़ॉर्म संपादन अनअनुमति',
+ content:
+ 'माफ़ कीजिए, आपको इस फ़ॉर्म को जमा करने के बाद संपादन करने की अनुमति नहीं है।',
+ },
+ FormDetails: {
+ title: 'फ़ॉर्म शीर्षक',
+ description: 'फ़ॉर्म विवरण',
+ },
+ },
+ FormDetails: {
+ title: 'फ़ॉर्म शीर्षक',
+ description: 'फ़ॉर्म विवरण',
+ },
Header: {
institute: 'संस्थान',
academics: 'शैक्षिक',
diff --git a/i18n/translations.ts b/i18n/translations.ts
index e319c4d4..1904c42c 100644
--- a/i18n/translations.ts
+++ b/i18n/translations.ts
@@ -77,7 +77,20 @@ export interface Translations {
lorem: string;
copyright: string;
};
- Forms: { title: string };
+ Forms: {
+ title: string;
+ FormNotFound: { title: string; content: string };
+ FormExpired: { title: string; content: string };
+ FormEditNotAllowed: { title: string; content: string };
+ FormDetails: {
+ title: string;
+ description: string;
+ };
+ };
+ FormDetails: {
+ title: string;
+ description: string;
+ };
Header: {
institute: string;
academics: string;
diff --git a/lib/validateResolver.ts b/lib/validateResolver.ts
new file mode 100644
index 00000000..98417f6b
--- /dev/null
+++ b/lib/validateResolver.ts
@@ -0,0 +1,80 @@
+import Ajv, { type ValidateFunction } from 'ajv';
+import AjvFormats from 'ajv-formats';
+import AjvErrors from 'ajv-errors';
+
+//configure AJV
+const ajv = new Ajv({
+ allErrors: true,
+ strict: false,
+ $data: true,
+});
+AjvFormats(ajv);
+AjvErrors(ajv);
+
+const schemasCache: Record> = {};
+
+const validateResolver = async (
+ data: FormData,
+ context: string | undefined,
+ schema: object
+) => {
+ if (!context) {
+ return {
+ values: {},
+ errors: {},
+ message: 'Context not provided.',
+ };
+ }
+ // Cache schemas for performance optimization
+ if (!schemasCache[context]) {
+ schemasCache[context] = ajv.compile(schema);
+ }
+
+ const validate = schemasCache[context];
+
+ try {
+ // Run validation
+ const isValid = validate(data);
+
+ if (isValid) {
+ // No errors, return data and empty error object
+ return {
+ values: data,
+ errors: {},
+ };
+ } else {
+ // Format errors for React Hook Form
+ if (!validate.errors) {
+ return {
+ values: {},
+ errors: {},
+ message: 'An error occurred during validation.',
+ };
+ }
+ const errorsFormatted = validate.errors.reduce(
+ (prev, current) => ({
+ ...prev,
+ [current.instancePath.replace('/', '')]: {
+ type: current.keyword,
+ message: current.message,
+ },
+ }),
+ {}
+ );
+
+ return {
+ values: {},
+ errors: errorsFormatted,
+ };
+ }
+ } catch (error) {
+ console.error('Validation error:', error);
+ return {
+ values: {},
+ errors: {},
+ message: 'An error occurred during validation. Please check your input.',
+ };
+ }
+};
+
+export { validateResolver };
diff --git a/package-lock.json b/package-lock.json
index 3c38c3e5..afa1ca6a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,6 +21,9 @@
"@t3-oss/env-nextjs": "^0.10.1",
"@types/negotiator": "^0.6.3",
"@vercel/postgres": "^0.8.0",
+ "ajv": "^8.12.0",
+ "ajv-errors": "^3.0.0",
+ "ajv-formats": "^3.0.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^1.0.0",
@@ -34,6 +37,7 @@
"next-auth": "^4.24.5",
"react": "^18",
"react-dom": "^18",
+ "react-hook-form": "^7.51.2",
"react-icons": "^5.0.1",
"tailwind-merge": "^2.2.1",
"typesense": "^1.8.2",
@@ -1717,6 +1721,28 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/@eslint/eslintrc/node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true
+ },
"node_modules/@eslint/js": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz",
@@ -4170,14 +4196,13 @@
}
},
"node_modules/ajv": {
- "version": "6.12.6",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
- "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
- "dev": true,
+ "version": "8.12.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
+ "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
"dependencies": {
"fast-deep-equal": "^3.1.1",
- "fast-json-stable-stringify": "^2.0.0",
- "json-schema-traverse": "^0.4.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2",
"uri-js": "^4.2.2"
},
"funding": {
@@ -4185,6 +4210,30 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/ajv-errors": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz",
+ "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==",
+ "peerDependencies": {
+ "ajv": "^8.0.1"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -5890,6 +5939,28 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/eslint/node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/eslint/node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true
+ },
"node_modules/esniff": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
@@ -5986,8 +6057,7 @@
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
- "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-diff": {
"version": "1.3.0",
@@ -7042,10 +7112,9 @@
}
},
"node_modules/json-schema-traverse": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
- "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
- "dev": true
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
@@ -8245,7 +8314,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
- "dev": true,
"engines": {
"node": ">=6"
}
@@ -8293,6 +8361,21 @@
"react": "^18.2.0"
}
},
+ "node_modules/react-hook-form": {
+ "version": "7.51.2",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.2.tgz",
+ "integrity": "sha512-y++lwaWjtzDt/XNnyGDQy6goHskFualmDlf+jzEZvjvz6KWDf7EboL7pUvRCzPTJd0EOPpdekYaQLEvvG6m6HA==",
+ "engines": {
+ "node": ">=12.22.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18"
+ }
+ },
"node_modules/react-icons": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.0.1.tgz",
@@ -8437,6 +8520,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/resolve": {
"version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
@@ -9323,7 +9414,6 @@
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
- "dev": true,
"dependencies": {
"punycode": "^2.1.0"
}
diff --git a/package.json b/package.json
index 057e67ef..36a251ce 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,9 @@
"@t3-oss/env-nextjs": "^0.10.1",
"@types/negotiator": "^0.6.3",
"@vercel/postgres": "^0.8.0",
+ "ajv": "^8.12.0",
+ "ajv-errors": "^3.0.0",
+ "ajv-formats": "^3.0.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^1.0.0",
@@ -45,6 +48,7 @@
"next-auth": "^4.24.5",
"react": "^18",
"react-dom": "^18",
+ "react-hook-form": "^7.51.2",
"react-icons": "^5.0.1",
"tailwind-merge": "^2.2.1",
"typesense": "^1.8.2",
diff --git a/server/db/schema/forms.schema.ts b/server/db/schema/forms.schema.ts
index 7399e801..8987b1de 100644
--- a/server/db/schema/forms.schema.ts
+++ b/server/db/schema/forms.schema.ts
@@ -1,29 +1,27 @@
-import { sql } from 'drizzle-orm';
+import { relations, sql } from 'drizzle-orm';
import {
boolean,
date,
integer,
+ json,
+ jsonb,
pgTable,
+ real,
serial,
smallint,
varchar,
} from 'drizzle-orm/pg-core';
+import { validationProperty } from '~/components/form/interfaces/form-elements';
+import { persons } from './persons.schema';
export const forms = pgTable('forms', {
id: serial('id').primaryKey(),
title: varchar('title').notNull(),
- description: varchar('description').notNull(),
- visibleTo: smallint('visible_to')
- .array()
- .default(sql`'{}'`)
- .notNull(),
- questions: integer('questions')
- .array()
- .default(sql`'{}'`)
- .notNull(),
+ description: varchar('description'),
onSubmitMessage: varchar('on_submit_message')
.default('Your response has been recorded.')
.notNull(),
+ isAnonymous: boolean('is_anonymous').default(false).notNull(),
isEditingAllowed: boolean('is_editing_allowed').notNull(),
isSingleResponse: boolean('is_single_response').default(true).notNull(),
isViewAnalyticsAllowed: boolean('is_view_analytics_allowed')
@@ -39,16 +37,78 @@ export const forms = pgTable('forms', {
.array()
.default(sql`'{}'`)
.notNull(),
- isPublished: boolean('is_published').notNull(),
+ isPublished: boolean('is_published').default(false).notNull(),
+ requiredQuestions: varchar('required_questions', { length: 3 })
+ .array()
+ .default(sql`'{}'`)
+ .notNull(),
+ questionValidations: json('question_validations').$type<
+ Record
+ >(),
+ type: varchar('type', {
+ enum: ['all', 'academic', 'factulty feedback', 'placement', 'other'],
+ }).notNull(),
});
+export const formRelations = relations(forms, ({ many }) => ({
+ questions: many(formQuestions),
+ submissions: many(formSubmissions),
+ visibleTo: many(formsVisibleToPersons),
+ modifiableBy: many(formsModifiableByPersons),
+}));
+
+export const formsVisibleToPersons = pgTable('forms_visible_to_persons', {
+ formId: integer('form_id')
+ .references(() => forms.id)
+ .notNull(),
+ personId: integer('person_id')
+ .references(() => persons.id)
+ .notNull(),
+});
+
+export const formsVisibleToPersonsRelation = relations(
+ formsVisibleToPersons,
+ ({ one }) => ({
+ forms: one(forms, {
+ fields: [formsVisibleToPersons.formId],
+ references: [forms.id],
+ }),
+ persons: one(persons, {
+ fields: [formsVisibleToPersons.personId],
+ references: [persons.id],
+ }),
+ })
+);
+
+export const formsModifiableByPersons = pgTable('forms_modifiable_by_persons', {
+ formId: integer('form_id')
+ .references(() => forms.id)
+ .notNull(),
+ personId: integer('person_id')
+ .references(() => persons.id)
+ .notNull(),
+});
+export const formsModifiableByPersonsRelation = relations(
+ formsModifiableByPersons,
+ ({ one }) => ({
+ forms: one(forms, {
+ fields: [formsModifiableByPersons.formId],
+ references: [forms.id],
+ }),
+ persons: one(persons, {
+ fields: [formsModifiableByPersons.personId],
+ references: [persons.id],
+ }),
+ })
+);
+
export const formQuestions = pgTable('form_questions', {
id: serial('id').primaryKey(),
formId: integer('form_id')
.references(() => forms.id)
.notNull(),
question: varchar('question').notNull(),
- description: varchar('description').notNull(),
+ description: varchar('description'),
isRequired: boolean('is_required').default(true).notNull(),
inputType: varchar('input_type').notNull(),
choices: varchar('choices')
@@ -63,14 +123,55 @@ export const formQuestions = pgTable('form_questions', {
.array()
.default(sql`'{}'`)
.notNull(),
- pageNumber: smallint('page_number').default(0).notNull(),
+ pageNumber: real('page_number').default(0).notNull(),
marks: smallint('marks').default(0).notNull(),
});
+export const formQuestionsRelations = relations(
+ formQuestions,
+ ({ one, many }) => ({
+ form: one(forms, {
+ fields: [formQuestions.formId],
+ references: [forms.id],
+ }),
+ answers: many(formAnswers),
+ })
+);
+
export const formSubmissions = pgTable('form_submissions', {
id: serial('id').primaryKey(),
formId: integer('form_id')
.references(() => forms.id)
.notNull(),
email: varchar('email', { length: 256 }).notNull(),
+ isSubmitted: boolean('is_submitted').default(false).notNull(),
});
+
+export const formSubmissionsRelations = relations(
+ formSubmissions,
+ ({ one, many }) => ({
+ form: one(forms, {
+ fields: [formSubmissions.formId],
+ references: [forms.id],
+ }),
+ answers: many(formAnswers),
+ })
+);
+
+export const formAnswers = pgTable('form_answers', {
+ id: serial('id').primaryKey(),
+ questionId: integer('question_id').notNull(),
+ submissionId: integer('submission_id').notNull(),
+ value: jsonb('value').notNull(),
+});
+
+export const formAnswersRelations = relations(formAnswers, ({ one }) => ({
+ questions: one(formQuestions, {
+ fields: [formAnswers.questionId],
+ references: [formQuestions.id],
+ }),
+ submission: one(formSubmissions, {
+ fields: [formAnswers.submissionId],
+ references: [formSubmissions.id],
+ }),
+}));
diff --git a/server/db/schema/index.ts b/server/db/schema/index.ts
index 3124a222..977d3ec7 100644
--- a/server/db/schema/index.ts
+++ b/server/db/schema/index.ts
@@ -11,6 +11,7 @@ export * from './departments.schema';
export * from './doctorates.schema';
export * from './faculty.schema';
export * from './faq.schema';
+export * from './forms.schema';
export * from './majors.schema';
export * from './notifications.schema';
export * from './persons.schema';
diff --git a/server/db/schema/persons.schema.ts b/server/db/schema/persons.schema.ts
index 81f5c7ab..01edc5f4 100644
--- a/server/db/schema/persons.schema.ts
+++ b/server/db/schema/persons.schema.ts
@@ -10,7 +10,7 @@ import {
varchar,
} from 'drizzle-orm/pg-core';
-import { roles } from '.';
+import { formsModifiableByPersons, formsVisibleToPersons, roles } from '.';
export const persons = pgTable(
'persons',
@@ -38,9 +38,11 @@ export const persons = pgTable(
}
);
-export const personsRelations = relations(persons, ({ one }) => ({
+export const personsRelations = relations(persons, ({ one, many }) => ({
role: one(roles, {
fields: [persons.roleId],
references: [roles.id],
}),
+ modifiableForms: many(formsModifiableByPersons),
+ fillableForms: many(formsVisibleToPersons),
}));