diff --git a/web/src/components/LanguageSelector/LanguageSelector.tsx b/web/src/components/LanguageSelector/LanguageSelector.tsx
index b3a6350bf..5e8662ceb 100644
--- a/web/src/components/LanguageSelector/LanguageSelector.tsx
+++ b/web/src/components/LanguageSelector/LanguageSelector.tsx
@@ -1,11 +1,16 @@
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
-import i18n from '@/i18n';
+import { useRouter } from '@tanstack/react-router';
import { FC } from 'react';
+import { useTranslation } from 'react-i18next';
import { Label } from '../ui/label';
export const LanguageSelector: FC = () => {
+ const { i18n } = useTranslation(); // not passing any namespace will use the defaultNS (by default set to 'translation')
+ const router = useRouter();
+
const changeLanguage = (lng: string) => {
i18n.changeLanguage(lng);
+ router.invalidate();
};
const options = [
diff --git a/web/src/components/layout/Layout.tsx b/web/src/components/layout/Layout.tsx
index 6e6c7f9cc..b737988d3 100644
--- a/web/src/components/layout/Layout.tsx
+++ b/web/src/components/layout/Layout.tsx
@@ -1,12 +1,13 @@
-import type { ReactNode } from 'react';
import type { FunctionComponent } from '@/common/types';
-import Breadcrumbs from './Breadcrumbs/Breadcrumbs';
+import type { ReactNode } from 'react';
import BackButton from './Breadcrumbs/BackButton';
+import Breadcrumbs from './Breadcrumbs/Breadcrumbs';
interface LayoutProps {
title?: string;
subtitle?: string;
enableBreadcrumbs?: boolean;
+ enableBackButton?: boolean;
breadcrumbs?: ReactNode;
backButton?: ReactNode;
actions?: ReactNode;
@@ -21,6 +22,7 @@ const Layout = ({
breadcrumbs,
children,
enableBreadcrumbs = true,
+ enableBackButton,
}: LayoutProps): FunctionComponent => {
return (
<>
@@ -29,6 +31,7 @@ const Layout = ({
{enableBreadcrumbs && (breadcrumbs || )}
{enableBreadcrumbs && (backButton || )}
+ {enableBackButton && (backButton || )}
{title}
{subtitle ?? {subtitle}
}
diff --git a/web/src/features/forms/components/Dashboard/Dashboard.tsx b/web/src/features/forms/components/Dashboard/Dashboard.tsx
index efc7d2d08..e7f5f1c6b 100644
--- a/web/src/features/forms/components/Dashboard/Dashboard.tsx
+++ b/web/src/features/forms/components/Dashboard/Dashboard.tsx
@@ -6,7 +6,7 @@ import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumn
import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable';
import { useConfirm } from '@/components/ui/alert-dialog-provider';
import { Badge } from '@/components/ui/badge';
-import { buttonVariants } from '@/components/ui/button';
+import { Button, buttonVariants } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
DropdownMenu,
@@ -31,9 +31,10 @@ import {
EllipsisVerticalIcon,
FunnelIcon,
PhotoIcon,
+ PlusIcon,
} from '@heroicons/react/24/outline';
import { useMutation } from '@tanstack/react-query';
-import { useNavigate, useRouter } from '@tanstack/react-router';
+import { Link, useNavigate, useRouter } from '@tanstack/react-router';
import { ColumnDef, createColumnHelper, Row } from '@tanstack/react-table';
import { useDebounce } from '@uidotdev/usehooks';
import { format } from 'date-fns';
@@ -41,7 +42,6 @@ import { useMemo, useState, type ReactElement } from 'react';
import { FormBase, FormStatus } from '../../models/form';
import { formsKeys, useForms } from '../../queries';
import AddTranslationsDialog, { useAddTranslationsDialog } from './AddTranslationsDialog';
-import CreateForm from './CreateForm';
import { FormFilters } from './FormFilters/FormFilters';
import EditFormAccessDialog, { useEditFormAccessDialog } from './EditFormAccessDialog';
import { useElectionRoundDetails } from '@/features/election-event/hooks/election-event-hooks';
@@ -725,9 +725,12 @@ export default function FormsDashboard(): ReactElement {
{i18n.t('electionEvent.observerForms.cardTitle')}
-
-
-
+
+
+
diff --git a/web/src/features/forms/components/EditForm/EditForm.tsx b/web/src/features/forms/components/EditForm/EditForm.tsx
index dfeceed8b..dc0a566a5 100644
--- a/web/src/features/forms/components/EditForm/EditForm.tsx
+++ b/web/src/features/forms/components/EditForm/EditForm.tsx
@@ -1,4 +1,4 @@
-import { QuestionType, ZFormType, type FunctionComponent } from '@/common/types';
+import { QuestionType, ZFormType } from '@/common/types';
import FormQuestionsEditor from '@/components/questionsEditor/FormQuestionsEditor';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Form } from '@/components/ui/form';
@@ -25,17 +25,12 @@ import { Button, buttonVariants } from '@/components/ui/button';
import { LanguageBadge } from '@/components/ui/language-badge';
import { toast } from '@/components/ui/use-toast';
import { useCurrentElectionRoundStore } from '@/context/election-round.store';
-import {
- cn,
- ensureTranslatedStringCorrectness,
- isNilOrWhitespace,
- isNotNilOrWhitespace
-} from '@/lib/utils';
+import { cn, ensureTranslatedStringCorrectness, isNilOrWhitespace, isNotNilOrWhitespace } from '@/lib/utils';
import { queryClient } from '@/main';
import { Route } from '@/routes/forms_.$formId.edit';
import { useMutation } from '@tanstack/react-query';
import { useBlocker, useNavigate, useRouter } from '@tanstack/react-router';
-import { useEffect, useState } from 'react';
+import { FC, useEffect, useState } from 'react';
import { UpdateFormRequest } from '../../models/form';
import { formDetailsQueryOptions, formsKeys } from '../../queries';
import {
@@ -49,7 +44,6 @@ import {
ZEditQuestionType,
ZTranslatedString,
} from '../../types';
-import { FormDetailsBreadcrumbs } from '../FormDetailsBreadcrumbs/FormDetailsBreadcrumbs';
import EditFormDetails from './EditFormDetails';
export const ZEditFormType = z
@@ -215,7 +209,11 @@ export const ZEditFormType = z
export type EditFormType = z.infer;
-export default function EditForm(): FunctionComponent {
+interface EditFormProps {
+ currentTab?: string;
+}
+
+const EditForm: FC = ({ currentTab }) => {
const { formId } = Route.useParams();
const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId);
const { data: formData } = useSuspenseQuery(formDetailsQueryOptions(currentElectionRoundId, formId));
@@ -383,7 +381,7 @@ export default function EditForm(): FunctionComponent {
description: ensureTranslatedStringCorrectness(formData.description, formData.languages),
formType: formData.formType,
questions: editQuestions,
- icon: formData.icon ?? ''
+ icon: formData.icon ?? '',
},
mode: 'all',
});
@@ -423,7 +421,7 @@ export default function EditForm(): FunctionComponent {
});
},
- onSuccess: async (_, { shouldExitEditor,electionRoundId }) => {
+ onSuccess: async (_, { shouldExitEditor, electionRoundId }) => {
toast({
title: 'Success',
description: 'Form updated successfully',
@@ -474,14 +472,21 @@ export default function EditForm(): FunctionComponent {
}
}, [form.formState.isSubmitSuccessful, form.reset]);
+ const [activeTab, setActiveTab] = useState(currentTab ?? 'form-details');
+
return (
}
- breadcrumbs={}
+ enableBreadcrumbs={false}
title={`${code} - ${name[languageCode]}`}>
+ );
+};
diff --git a/web/src/features/forms/components/FormBuilder/components/FormBuilderScreenReuse.tsx b/web/src/features/forms/components/FormBuilder/components/FormBuilderScreenReuse.tsx
new file mode 100644
index 000000000..753655e71
--- /dev/null
+++ b/web/src/features/forms/components/FormBuilder/components/FormBuilderScreenReuse.tsx
@@ -0,0 +1,132 @@
+import Layout from '@/components/layout/Layout';
+import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader';
+import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { useCurrentElectionRoundStore } from '@/context/election-round.store';
+import { useCreateFormFromForm, useCreateFormFromFormDialog } from '@/features/forms/hooks';
+import { FormBase } from '@/features/forms/models/form';
+import { useForms } from '@/features/forms/queries';
+import { cn, mapFormType } from '@/lib/utils';
+import { EllipsisVerticalIcon } from '@heroicons/react/24/outline';
+import { ColumnDef, Row } from '@tanstack/react-table';
+import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
+import { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PreviewFormReuseDialog } from './PreviewDialogs';
+
+export const FormBuilderScreenReuse: FC = () => {
+ const { t } = useTranslation('translation', { keyPrefix: 'electionEvent.form' });
+ const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId);
+ const createFormFromFormDialog = useCreateFormFromFormDialog();
+ const { createForm } = useCreateFormFromForm();
+
+ const getSubrows = (originalRow: FormBase, index: number): undefined | FormBase[] => {
+ if (originalRow.languages.length === 0) return undefined;
+
+ // we need to have subrows only for translations
+ return originalRow.languages
+ .filter((languageCode) => originalRow.defaultLanguage !== languageCode)
+ .map((languageCode) => ({
+ ...originalRow,
+ languages: [],
+ code: `${originalRow.code} - ${languageCode}`,
+ defaultLanguage: languageCode,
+ }));
+ };
+
+ const getRowClassName = (row: Row): string => cn({ 'bg-secondary-300 bg-opacity-[.15]': row.depth === 1 });
+
+ const templatesColDefs: ColumnDef[] = [
+ {
+ header: '',
+ id: 'colapse',
+ cell: ({ row, getValue }) => (
+
+ {row.getCanExpand() ? (
+
+ ) : (
+ ''
+ )}
+ {getValue()}
+
+ ),
+ enableResizing: false,
+ },
+ {
+ accessorKey: 'code',
+ header: ({ column }) => ,
+ },
+
+ {
+ id: 'name',
+ accessorFn: (row, _) => row.name[row.defaultLanguage],
+ header: ({ column }) => ,
+ },
+
+ {
+ accessorKey: 'formTemplateType',
+ accessorFn: (row, _) => mapFormType(row.formType),
+ enableSorting: false,
+ enableResizing: false,
+ header: ({ column }) => ,
+ cell: ({ row }) => (row.depth === 0 ? row.original.formType : ''),
+ },
+
+ {
+ accessorKey: 'defaultLanguage',
+ enableSorting: false,
+ enableResizing: false,
+ header: ({ column }) => ,
+ },
+ {
+ header: '',
+ id: 'action',
+ enableSorting: false,
+ enableResizing: false,
+ cell: ({ row }) => (
+
+
+
+
+
+ createFormFromFormDialog.trigger(row.original.id, row.original.defaultLanguage)}>
+ Preview template
+
+ createForm({ formId: row.original.id, languageCode: row.original.defaultLanguage })}>
+ Use template
+
+
+
+ ),
+ },
+ ];
+
+ return (
+
+ useForms(currentElectionRoundId, params)}
+ getSubrows={getSubrows}
+ getRowClassName={getRowClassName}
+ />
+
+
+ );
+};
diff --git a/web/src/features/forms/components/FormBuilder/components/FormBuilderScreenScratch.tsx b/web/src/features/forms/components/FormBuilder/components/FormBuilderScreenScratch.tsx
new file mode 100644
index 000000000..5fbd95246
--- /dev/null
+++ b/web/src/features/forms/components/FormBuilder/components/FormBuilderScreenScratch.tsx
@@ -0,0 +1,13 @@
+import Layout from '@/components/layout/Layout';
+import { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+import { CreateFormPage } from './CreateFormPage';
+
+export const FormBuilderScreenScratch: FC = () => {
+ const { t } = useTranslation('translation', { keyPrefix: 'electionEvent.form' });
+ return (
+
+
+
+ );
+};
diff --git a/web/src/features/forms/components/FormBuilder/components/FormBuilderScreenStart.tsx b/web/src/features/forms/components/FormBuilder/components/FormBuilderScreenStart.tsx
new file mode 100644
index 000000000..d993281f9
--- /dev/null
+++ b/web/src/features/forms/components/FormBuilder/components/FormBuilderScreenStart.tsx
@@ -0,0 +1,80 @@
+import Layout from '@/components/layout/Layout';
+import { NavigateBack } from '@/components/NavigateBack/NavigateBack';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { ClipboardDocumentListIcon, DocumentPlusIcon, DocumentTextIcon } from '@heroicons/react/24/outline';
+import { Link } from '@tanstack/react-router';
+import { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+
+interface FormBuilderChoiceIconProps {
+ type: FormBuilderChoice;
+}
+
+const FormBuilderChoiceIcon: FC = ({ type }) => {
+ const classes = 'stroke-purple-900 h-16 w-16 md:h-32 md:w-32';
+ switch (type) {
+ case 'scratch':
+ return ;
+
+ case 'template':
+ return ;
+
+ case 'reuse':
+ return ;
+
+ default:
+ return <>>;
+ }
+};
+
+type FormBuilderChoice = 'scratch' | 'template' | 'reuse';
+
+interface FormBuilderChoiceProps {
+ type: FormBuilderChoice;
+}
+const FormBuilderChoice: FC = ({ type }) => {
+ const { t } = useTranslation('translation', { keyPrefix: `electionEvent.form.${type}` });
+
+ return (
+
+
+ {t('title')}
+
+
+
+
+
{t('description')}
+
+
+
+
+
+
+
+ );
+};
+
+export const FormBuilderScreenStart: FC = () => {
+ const { t } = useTranslation('translation', { keyPrefix: 'electionEvent.form' });
+
+ return (
+ }>
+
+
+
+ {/* */}
+
+
+ );
+};
diff --git a/web/src/features/forms/components/FormBuilder/components/FormBuilderScreenTemplate.tsx b/web/src/features/forms/components/FormBuilder/components/FormBuilderScreenTemplate.tsx
new file mode 100644
index 000000000..64a5d423e
--- /dev/null
+++ b/web/src/features/forms/components/FormBuilder/components/FormBuilderScreenTemplate.tsx
@@ -0,0 +1,130 @@
+import Layout from '@/components/layout/Layout';
+import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader';
+import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { useCreateFormFromTemplate, usePreviewTemplateDialog } from '@/features/forms/hooks';
+import { FormBase } from '@/features/forms/models/form';
+import { useFormTemplates } from '@/features/forms/queries';
+import { cn, mapFormType } from '@/lib/utils';
+import { EllipsisVerticalIcon } from '@heroicons/react/24/outline';
+import { ColumnDef, Row } from '@tanstack/react-table';
+import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
+import { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PreviewTemplateDialog } from './PreviewDialogs';
+
+export const FormBuilderScreenTemplate: FC = () => {
+ const { t } = useTranslation('translation', { keyPrefix: 'electionEvent.form' });
+ const previewTemplateDialog = usePreviewTemplateDialog();
+ const { createForm } = useCreateFormFromTemplate();
+ const getSubrows = (originalRow: FormBase, index: number): undefined | FormBase[] => {
+ if (originalRow.languages.length === 0) return undefined;
+
+ // we need to have subrows only for translations
+ return originalRow.languages
+ .filter((languageCode) => originalRow.defaultLanguage !== languageCode)
+ .map((languageCode) => ({
+ ...originalRow,
+ languages: [],
+ code: `${originalRow.code} - ${languageCode}`,
+ defaultLanguage: languageCode,
+ }));
+ };
+
+ const getRowClassName = (row: Row): string => cn({ 'bg-secondary-300 bg-opacity-[.15]': row.depth === 1 });
+
+ const templatesColDefs: ColumnDef[] = [
+ {
+ header: '',
+ id: 'colapse',
+ cell: ({ row, getValue }) => (
+
+ {row.getCanExpand() ? (
+
+ ) : (
+ ''
+ )}
+ {getValue()}
+
+ ),
+ enableResizing: false,
+ },
+ {
+ accessorKey: 'code',
+ header: ({ column }) => ,
+ },
+
+ {
+ id: 'name',
+ accessorFn: (row, _) => row.name[row.defaultLanguage],
+ header: ({ column }) => ,
+ },
+
+ {
+ accessorKey: 'formTemplateType',
+ accessorFn: (row, _) => mapFormType(row.formType),
+ enableSorting: false,
+ enableResizing: false,
+ header: ({ column }) => ,
+ cell: ({ row }) => (row.depth === 0 ? mapFormType(row.original.formType) : ''),
+ },
+
+ {
+ accessorKey: 'defaultLanguage',
+ enableSorting: false,
+ enableResizing: false,
+ header: ({ column }) => ,
+ },
+
+ {
+ header: '',
+ id: 'action',
+ enableSorting: false,
+ enableResizing: false,
+ cell: ({ row }) => (
+
+
+
+
+
+ previewTemplateDialog.trigger(row.original.id, row.original.defaultLanguage)}>
+ Preview template
+
+ createForm({ templateId: row.original.id, languageCode: row.original.defaultLanguage })}>
+ Use template
+
+
+
+ ),
+ },
+ ];
+
+ return (
+
+ useFormTemplates()}
+ getSubrows={getSubrows}
+ getRowClassName={getRowClassName}
+ />
+
+
+ );
+};
diff --git a/web/src/features/forms/components/FormBuilder/components/PreviewDialogs.tsx b/web/src/features/forms/components/FormBuilder/components/PreviewDialogs.tsx
new file mode 100644
index 000000000..3ca0869a8
--- /dev/null
+++ b/web/src/features/forms/components/FormBuilder/components/PreviewDialogs.tsx
@@ -0,0 +1,186 @@
+import { Dialog, DialogClose, DialogContent, DialogFooter, DialogTitle } from '@/components/ui/dialog';
+import { formDetailsQueryOptions, useFormTemplateDetails } from '@/features/forms/queries';
+import { useTranslation } from 'react-i18next';
+
+import { Button } from '@/components/ui/button';
+import { useCurrentElectionRoundStore } from '@/context/election-round.store';
+import {
+ useCreateFormFromForm,
+ useCreateFormFromFormDialog,
+ useCreateFormFromTemplate,
+ usePreviewTemplateDialog,
+} from '@/features/forms/hooks';
+import { useSuspenseQuery } from '@tanstack/react-query';
+import { FC, ReactNode } from 'react';
+import { FormQuestions } from '../../FormQuestions';
+
+interface TemplateDetailProps {
+ name: string;
+ content: string | undefined;
+ noContentErrorMessage?: string;
+}
+
+const TemplateDetail: FC = ({ name, content, noContentErrorMessage }) => {
+ return (
+
+
{name}
+
{content ?? noContentErrorMessage}
+
+ );
+};
+
+interface PreviewTemplateDialogProps {
+ isOpen: boolean;
+ title: string;
+ confirmBtn: ReactNode;
+ details: ReactNode;
+ questionsList: ReactNode;
+ onOpenChange: (open: boolean) => void;
+}
+
+const PreviewFormOrTemplateDialog: FC = (props) => {
+ const { isOpen, onOpenChange, title, confirmBtn, details, questionsList } = props;
+ return (
+
+ );
+};
+
+export const PreviewTemplateDialog = () => {
+ const { t } = useTranslation('translation', { keyPrefix: 'electionEvent.form.template' });
+ const { isOpen, id, languageCode, trigger, dismiss } = usePreviewTemplateDialog();
+ const { data } = useFormTemplateDetails(id);
+ const { createForm } = useCreateFormFromTemplate();
+
+ const onOpenChange = (open: boolean) => {
+ if (open) trigger(id, languageCode);
+ else dismiss();
+ };
+
+ const filteredLanguages = data?.languages && data?.languages.filter((language) => language !== languageCode);
+
+ return (
+
+
+
+
+ {filteredLanguages && filteredLanguages?.length > 0 && (
+
+ )}
+ >
+ }
+ questionsList={
+
+ }
+ confirmBtn={
+
+ }
+ />
+ );
+};
+
+export const PreviewFormReuseDialog = () => {
+ const { t } = useTranslation('translation', { keyPrefix: 'electionEvent.form.template' });
+ const { isOpen, id: formId, languageCode, trigger, dismiss } = useCreateFormFromFormDialog();
+ const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId);
+
+ const { data: formData } = useSuspenseQuery(formDetailsQueryOptions(currentElectionRoundId, formId));
+ const { createForm } = useCreateFormFromForm();
+
+ const onOpenChange = (open: boolean) => {
+ if (open) trigger(formId, languageCode);
+ else dismiss();
+ };
+
+ const filteredLanguages = formData?.languages && formData?.languages.filter((language) => language !== languageCode);
+
+ return (
+
+
+
+
+ {filteredLanguages && filteredLanguages?.length > 0 && (
+
+ )}
+ >
+ }
+ questionsList={
+
+ }
+ confirmBtn={
+
+ }
+ />
+ );
+};
diff --git a/web/src/features/forms/components/FormQuestions.tsx b/web/src/features/forms/components/FormQuestions.tsx
new file mode 100644
index 000000000..afa43d9b8
--- /dev/null
+++ b/web/src/features/forms/components/FormQuestions.tsx
@@ -0,0 +1,116 @@
+import { BaseQuestion } from '@/common/types';
+import { FC } from 'react';
+
+import {
+ isDateQuestion,
+ isMultiSelectQuestion,
+ isNumberQuestion,
+ isRatingQuestion,
+ isSingleSelectQuestion,
+ isTextQuestion,
+} from '@/common/guards';
+import PreviewDateQuestion from '@/components/questionsEditor/preview/PreviewDateQuestion';
+import PreviewMultiSelectQuestion from '@/components/questionsEditor/preview/PreviewMultiSelectQuestion';
+import PreviewNumberQuestion from '@/components/questionsEditor/preview/PreviewNumberQuestion';
+import PreviewRatingQuestion from '@/components/questionsEditor/preview/PreviewRatingQuestion';
+import PreviewSingleSelectQuestion from '@/components/questionsEditor/preview/PreviewSingleSelectQuestion';
+import PreviewTextQuestion from '@/components/questionsEditor/preview/PreviewTextQuestion';
+
+interface FormQuestionsProps {
+ questions: BaseQuestion[] | undefined;
+ languageCode: string;
+ title: string;
+ noContentMessage: string;
+}
+
+export const FormQuestions: FC = ({ questions, languageCode, title, noContentMessage }) => {
+ if (questions?.length === 0)
+ return (
+
+
{title}
+
{noContentMessage}
+
+ );
+ return (
+
+
{`${title}: ${questions?.length}`}
+
+ {questions?.map((question) => (
+ <>
+ {isTextQuestion(question) && (
+
+ )}
+
+ {isNumberQuestion(question) && (
+
+ )}
+
+ {isDateQuestion(question) && (
+
+ )}
+
+ {isRatingQuestion(question) && (
+
+ )}
+
+ {isMultiSelectQuestion(question) && (
+
({
+ optionId: o.id,
+ isFreeText: o.isFreeText,
+ text: o.text[languageCode],
+ })) ?? []
+ }
+ code={question.code}
+ />
+ )}
+
+ {isSingleSelectQuestion(question) && (
+ ({
+ optionId: o.id,
+ isFreeText: o.isFreeText,
+ text: o.text[languageCode],
+ })) ?? []
+ }
+ code={question.code}
+ />
+ )}
+ >
+ ))}
+
+ );
+};
diff --git a/web/src/features/forms/hooks.ts b/web/src/features/forms/hooks.ts
new file mode 100644
index 000000000..8dcacc744
--- /dev/null
+++ b/web/src/features/forms/hooks.ts
@@ -0,0 +1,133 @@
+import { authApi } from '@/common/auth-api';
+import { toast } from '@/components/ui/use-toast';
+import { useCurrentElectionRoundStore } from '@/context/election-round.store';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useNavigate } from '@tanstack/react-router';
+import { create } from 'zustand';
+import { FormFull } from './models/form';
+import { formsKeys } from './queries';
+
+export interface PreviewDialogProps {
+ isOpen: boolean;
+ id: string;
+ languageCode: string;
+ trigger: (templateId: string, languageCode: string) => void;
+ dismiss: VoidFunction;
+}
+
+export const usePreviewTemplateDialog = create((set) => ({
+ isOpen: false,
+ id: '',
+ languageCode: '',
+ trigger: (templateId: string, languageCode: string) => set({ id: templateId, languageCode, isOpen: true }),
+ dismiss: () => set({ isOpen: false }),
+}));
+
+type FormFromTemplateDto = {
+ templateId: string;
+ languageCode: string;
+};
+
+export const useCreateFormFromTemplate = () => {
+ const { trigger, dismiss, isOpen } = usePreviewTemplateDialog();
+ const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId);
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const createFormFromTemplateMutation = useMutation({
+ mutationFn: ({ templateId, languageCode }: FormFromTemplateDto) => {
+ return authApi.post(`/election-rounds/${currentElectionRoundId}/forms:fromTemplate`, {
+ templateId,
+ defaultLanguage: languageCode,
+ languages: [languageCode],
+ });
+ },
+ onSuccess: (response) => {
+ toast({
+ title: 'Success',
+ description: 'Form created from template',
+ });
+ queryClient.invalidateQueries({ queryKey: formsKeys.all(currentElectionRoundId) });
+ navigate({ to: '/forms/$formId/edit', params: { formId: response.data.id } });
+ },
+
+ onError: (err) =>
+ toast({
+ title: 'Error creating form from template',
+ description: 'Please contact tech support',
+ variant: 'destructive',
+ }),
+
+ onSettled: () => {
+ if (isOpen) dismiss();
+ },
+ });
+
+ const createForm = (dto: FormFromTemplateDto) => {
+ return createFormFromTemplateMutation.mutate(dto);
+ };
+
+ const openFormTemplatePreview = (dto: FormFromTemplateDto) => {
+ trigger(dto.templateId, dto.languageCode);
+ };
+
+ return { openFormTemplatePreview, createForm };
+};
+
+export const useCreateFormFromFormDialog = create((set) => ({
+ isOpen: false,
+ id: '',
+ languageCode: '',
+ trigger: (formId: string, languageCode: string) => set({ id: formId, languageCode, isOpen: true }),
+ dismiss: () => set({ isOpen: false }),
+}));
+
+type FormReuseDto = {
+ formId: string;
+ languageCode: string;
+};
+
+export const useCreateFormFromForm = () => {
+ const { trigger, dismiss, isOpen } = useCreateFormFromFormDialog();
+ const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId);
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const createFormFromFormMutation = useMutation({
+ mutationFn: ({ formId, languageCode }: FormReuseDto) => {
+ return authApi.post(`/election-rounds/${currentElectionRoundId}/forms:fromForm`, {
+ formId,
+ defaultLanguage: languageCode,
+ languages: [languageCode],
+ formElectionRoundId: currentElectionRoundId,
+ });
+ },
+ onSuccess: (response) => {
+ toast({
+ title: 'Success',
+ description: 'Form created from template',
+ });
+ queryClient.invalidateQueries({ queryKey: formsKeys.all(currentElectionRoundId) });
+ navigate({ to: '/forms/$formId/edit', params: { formId: response.data.id } });
+ },
+
+ onError: (err) =>
+ toast({
+ title: 'Error creating form from template',
+ description: 'Please contact tech support',
+ variant: 'destructive',
+ }),
+
+ onSettled: () => {
+ if (isOpen) dismiss();
+ },
+ });
+
+ const createForm = (dto: FormReuseDto) => {
+ return createFormFromFormMutation.mutate(dto);
+ };
+
+ const openReuseFormPreview = (dto: FormReuseDto) => {
+ trigger(dto.formId, dto.languageCode);
+ };
+
+ return { openReuseFormPreview, createForm };
+};
diff --git a/web/src/features/forms/queries.ts b/web/src/features/forms/queries.ts
index 70d5988f5..b116203ed 100644
--- a/web/src/features/forms/queries.ts
+++ b/web/src/features/forms/queries.ts
@@ -1,9 +1,9 @@
import { authApi } from '@/common/auth-api';
import { DataTableParameters, PageResponse } from '@/common/types';
-import { UseQueryResult, queryOptions, useQuery } from '@tanstack/react-query';
-import { FormBase, FormFull } from './models/form';
import { buildURLSearchParams } from '@/lib/utils';
import { queryClient } from '@/main';
+import { UseQueryResult, queryOptions, useQuery } from '@tanstack/react-query';
+import { FormBase, FormFull } from './models/form';
const STALE_TIME = 1000 * 60 * 5; // five minutes
export const formsKeys = {
@@ -54,7 +54,7 @@ export function useForms(
const searchParams = buildURLSearchParams(params);
const response = await authApi.get>(`/election-rounds/${electionRoundId}/forms`, {
- params: searchParams
+ params: searchParams,
});
if (response.status !== 200) {
@@ -68,6 +68,36 @@ export function useForms(
return response.data;
},
enabled: !!electionRoundId,
- staleTime: STALE_TIME
+ staleTime: STALE_TIME,
+ });
+}
+
+export function useFormTemplates(): UseQueryResult, Error> {
+ return useQuery({
+ queryKey: ['form-templates'],
+ queryFn: async () => {
+ const response = await authApi.get>(`/form-templates`);
+
+ if (response.status !== 200) {
+ throw new Error('Failed to fetch form templates');
+ }
+
+ return response.data;
+ },
+ });
+}
+
+export function useFormTemplateDetails(templateId: string): UseQueryResult {
+ return useQuery({
+ queryKey: ['form-templates', templateId],
+ queryFn: async () => {
+ const response = await authApi.get(`/form-templates/${templateId}`);
+
+ if (response.status !== 200) {
+ throw new Error('Failed to fetch form template data');
+ }
+
+ return response.data;
+ },
});
}
diff --git a/web/src/i18n.ts b/web/src/i18n.ts
index 882bfdd50..1beed8dd5 100644
--- a/web/src/i18n.ts
+++ b/web/src/i18n.ts
@@ -16,15 +16,19 @@ const resources = {
export type Dict = typeof resources.en;
+const DETECTION_OPTIONS = {
+ order: ['localStorage', 'navigator'],
+ caches: ['localStorage'],
+};
+
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
debug: true,
- lng: 'en',
- supportedLngs: ['en', 'ro'],
defaultNS: 'translation',
contextSeparator: '|',
+ detection: DETECTION_OPTIONS,
interpolation: {
escapeValue: false,
},
diff --git a/web/src/locales/en.json b/web/src/locales/en.json
index 18fe0c2bf..517c70ab5 100644
--- a/web/src/locales/en.json
+++ b/web/src/locales/en.json
@@ -274,6 +274,41 @@
"level5": "Level 5"
},
"resetFilters": "Reset filters"
+ },
+ "form": {
+ "title": "Create new form",
+ "subtitle": "Choose how you'd like to start your form.",
+ "scratch": {
+ "title": "Start from scratch",
+ "description": "Create a completely new form",
+ "buttonText": "Begin new form"
+ },
+ "template": {
+ "title": "Start from a VM template",
+ "description": "Choose from available templates",
+ "buttonText": "Browse templates",
+ "useTemplate": "Use template",
+ "formBaseLanguage": "Base language",
+ "formBaseLanguageErr": "No base language specified",
+ "formDescription": "Description",
+ "formDescriptionErr": "This template has no description",
+ "formOtherLanguages": "Other languages",
+ "formQuestions": "Questions",
+ "formQuestionsErr": "No questions included in this template"
+ },
+ "reuse": {
+ "title": "Reuse a previous form",
+ "description": "Reuse or modify a form from a past election event you have monitored",
+ "buttonText": "Reuse previous form",
+ "reuseForm": "REuse form",
+ "formBaseLanguage": "Base language",
+ "formBaseLanguageErr": "No base language specified",
+ "formDescription": "Description",
+ "formDescriptionErr": "This template has no description",
+ "formOtherLanguages": "Other languages",
+ "formQuestions": "Questions",
+ "formQuestionsErr": "No questions included in this template"
+ }
}
},
"pagination": {
diff --git a/web/src/locales/ro.json b/web/src/locales/ro.json
index 076d2f110..e8ecf5da7 100644
--- a/web/src/locales/ro.json
+++ b/web/src/locales/ro.json
@@ -265,12 +265,16 @@
"level5": "Level 5"
},
"resetFilters": "Reset filters"
+ },
+ "form": {
+ "title": "Creează un nou formular",
+ "subtitle": "Alege modul în care vrei să începi."
}
},
"pagination": {
"dataTable": {
"resultsSummary": "Showing {{start}} to {{end}} of {{total}} results",
- "perPage": "per page",
+ "perPage": "per pagină",
"goToFirstPage": "Go to first page",
"goToPreviousPage": "Go to previous page",
"goToNextPage": "Go to next page",
diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts
index f26be3745..1b920e74b 100644
--- a/web/src/routeTree.gen.ts
+++ b/web/src/routeTree.gen.ts
@@ -29,6 +29,7 @@ import { Route as NgosNgoIdImport } from './routes/ngos/$ngoId'
import { Route as MonitoringObserversImportImport } from './routes/monitoring-observers/import'
import { Route as MonitoringObserversCreateNewMessageImport } from './routes/monitoring-observers/create-new-message'
import { Route as MonitoringObserversTabImport } from './routes/monitoring-observers/$tab'
+import { Route as FormsNewImport } from './routes/forms/new'
import { Route as FormsFormIdImport } from './routes/forms/$formId'
import { Route as ElectionRoundsElectionRoundIdImport } from './routes/election-rounds/$electionRoundId'
import { Route as ElectionEventTabImport } from './routes/election-event/$tab'
@@ -45,6 +46,9 @@ import { Route as ObserverGuidesEditGuideIdImport } from './routes/observer-guid
import { Route as MonitoringObserversPushMessagesIdImport } from './routes/monitoring-observers/push-messages.$id'
import { Route as MonitoringObserversEditMonitoringObserverIdImport } from './routes/monitoring-observers/edit.$monitoringObserverId'
import { Route as FormsFormIdEditImport } from './routes/forms_.$formId.edit'
+import { Route as FormsNewTemplateImport } from './routes/forms/new_.template'
+import { Route as FormsNewScratchImport } from './routes/forms/new_.scratch'
+import { Route as FormsNewReuseImport } from './routes/forms/new_.reuse'
import { Route as FormsFormIdLanguageCodeImport } from './routes/forms/$formId_.$languageCode'
import { Route as CitizenNotificationsViewNotificationIdImport } from './routes/citizen-notifications/view.$notificationId'
import { Route as CitizenGuidesViewGuideIdImport } from './routes/citizen-guides/view.$guideId'
@@ -150,6 +154,11 @@ const MonitoringObserversTabRoute = MonitoringObserversTabImport.update({
getParentRoute: () => rootRoute,
} as any)
+const FormsNewRoute = FormsNewImport.update({
+ path: '/forms/new',
+ getParentRoute: () => rootRoute,
+} as any)
+
const FormsFormIdRoute = FormsFormIdImport.update({
path: '/forms/$formId',
getParentRoute: () => rootRoute,
@@ -237,6 +246,21 @@ const FormsFormIdEditRoute = FormsFormIdEditImport.update({
getParentRoute: () => rootRoute,
} as any)
+const FormsNewTemplateRoute = FormsNewTemplateImport.update({
+ path: '/forms/new/template',
+ getParentRoute: () => rootRoute,
+} as any)
+
+const FormsNewScratchRoute = FormsNewScratchImport.update({
+ path: '/forms/new/scratch',
+ getParentRoute: () => rootRoute,
+} as any)
+
+const FormsNewReuseRoute = FormsNewReuseImport.update({
+ path: '/forms/new/reuse',
+ getParentRoute: () => rootRoute,
+} as any)
+
const FormsFormIdLanguageCodeRoute = FormsFormIdLanguageCodeImport.update({
path: '/forms/$formId/$languageCode',
getParentRoute: () => rootRoute,
@@ -334,6 +358,10 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof FormsFormIdImport
parentRoute: typeof rootRoute
}
+ '/forms/new': {
+ preLoaderRoute: typeof FormsNewImport
+ parentRoute: typeof rootRoute
+ }
'/monitoring-observers/$tab': {
preLoaderRoute: typeof MonitoringObserversTabImport
parentRoute: typeof rootRoute
@@ -418,6 +446,18 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof FormsFormIdLanguageCodeImport
parentRoute: typeof rootRoute
}
+ '/forms/new/reuse': {
+ preLoaderRoute: typeof FormsNewReuseImport
+ parentRoute: typeof rootRoute
+ }
+ '/forms/new/scratch': {
+ preLoaderRoute: typeof FormsNewScratchImport
+ parentRoute: typeof rootRoute
+ }
+ '/forms/new/template': {
+ preLoaderRoute: typeof FormsNewTemplateImport
+ parentRoute: typeof rootRoute
+ }
'/forms/$formId/edit': {
preLoaderRoute: typeof FormsFormIdEditImport
parentRoute: typeof rootRoute
@@ -499,6 +539,7 @@ export const routeTree = rootRoute.addChildren([
ElectionEventTabRoute,
ElectionRoundsElectionRoundIdRoute,
FormsFormIdRoute,
+ FormsNewRoute,
MonitoringObserversTabRoute,
MonitoringObserversCreateNewMessageRoute,
MonitoringObserversImportRoute,
@@ -520,6 +561,9 @@ export const routeTree = rootRoute.addChildren([
CitizenGuidesViewGuideIdRoute,
CitizenNotificationsViewNotificationIdRoute,
FormsFormIdLanguageCodeRoute,
+ FormsNewReuseRoute,
+ FormsNewScratchRoute,
+ FormsNewTemplateRoute,
FormsFormIdEditRoute,
MonitoringObserversEditMonitoringObserverIdRoute,
MonitoringObserversPushMessagesIdRoute,
diff --git a/web/src/routes/forms/new.tsx b/web/src/routes/forms/new.tsx
new file mode 100644
index 000000000..b53bfaba2
--- /dev/null
+++ b/web/src/routes/forms/new.tsx
@@ -0,0 +1,14 @@
+import { FormBuilderScreenStart } from '@/features/forms/components/FormBuilder/components/FormBuilderScreenStart';
+import { redirectIfNotAuth } from '@/lib/utils';
+import { createFileRoute } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/forms/new')({
+ beforeLoad: () => {
+ redirectIfNotAuth();
+ },
+ component: CreateNewForm,
+});
+
+function CreateNewForm() {
+ return ;
+}
diff --git a/web/src/routes/forms/new_.reuse.tsx b/web/src/routes/forms/new_.reuse.tsx
new file mode 100644
index 000000000..b6798e68b
--- /dev/null
+++ b/web/src/routes/forms/new_.reuse.tsx
@@ -0,0 +1,14 @@
+import { FormBuilderScreenReuse } from '@/features/forms/components/FormBuilder/components/FormBuilderScreenReuse';
+import { redirectIfNotAuth } from '@/lib/utils';
+import { createFileRoute } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/forms/new/reuse')({
+ beforeLoad: () => {
+ redirectIfNotAuth();
+ },
+ component: CreateNewFormFromOldForm,
+});
+
+function CreateNewFormFromOldForm() {
+ return ;
+}
diff --git a/web/src/routes/forms/new_.scratch.tsx b/web/src/routes/forms/new_.scratch.tsx
new file mode 100644
index 000000000..6d94079be
--- /dev/null
+++ b/web/src/routes/forms/new_.scratch.tsx
@@ -0,0 +1,14 @@
+import { FormBuilderScreenScratch } from '@/features/forms/components/FormBuilder/components/FormBuilderScreenScratch';
+import { redirectIfNotAuth } from '@/lib/utils';
+import { createFileRoute } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/forms/new/scratch')({
+ beforeLoad: () => {
+ redirectIfNotAuth();
+ },
+ component: CreateNewFormFromScratch,
+});
+
+function CreateNewFormFromScratch() {
+ return ;
+}
diff --git a/web/src/routes/forms/new_.template.tsx b/web/src/routes/forms/new_.template.tsx
new file mode 100644
index 000000000..e03ffa468
--- /dev/null
+++ b/web/src/routes/forms/new_.template.tsx
@@ -0,0 +1,14 @@
+import { FormBuilderScreenTemplate } from '@/features/forms/components/FormBuilder/components/FormBuilderScreenTemplate';
+import { redirectIfNotAuth } from '@/lib/utils';
+import { createFileRoute } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/forms/new/template')({
+ beforeLoad: () => {
+ redirectIfNotAuth();
+ },
+ component: CreateNewFormFromTemplate,
+});
+
+function CreateNewFormFromTemplate() {
+ return ;
+}
diff --git a/web/src/routes/forms_.$formId.edit.tsx b/web/src/routes/forms_.$formId.edit.tsx
index 3f1c21461..c5e80008f 100644
--- a/web/src/routes/forms_.$formId.edit.tsx
+++ b/web/src/routes/forms_.$formId.edit.tsx
@@ -3,6 +3,12 @@ import { formDetailsQueryOptions } from '@/features/forms/queries';
import { redirectIfNotAuth } from '@/lib/utils';
import { createFileRoute } from '@tanstack/react-router';
+import { z } from 'zod';
+
+const editFormParamsSchema = z.object({
+ tab: z.enum(['form-details', 'questions']).catch('form-details').optional(),
+});
+
export const Route = createFileRoute('/forms/$formId/edit')({
component: Edit,
loader: ({ context: { queryClient, currentElectionRoundContext }, params: { formId } }) => {
@@ -13,12 +19,14 @@ export const Route = createFileRoute('/forms/$formId/edit')({
beforeLoad: () => {
redirectIfNotAuth();
},
+ validateSearch: (search) => editFormParamsSchema.parse(search),
});
function Edit() {
+ const { tab } = Route.useSearch();
return (
-
+
);
}