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 ( +
+ {label && ( + + )} + {description && ( +

{description}

+ )} + + no results found. +

+ } + {...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 ( +
+ {label && ( + + )} + {description && ( +

{description}

+ )} + + {items.map((item) => ( +
+ + +
+ ))} +
+
+ ); +}); + +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 ( +
+ {label && ( + + )} + {description && ( +

{description}

+ )} + +
+ ); +}); + +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}

+ )} +