diff --git a/frontend/src/components/Checkbox/Checkbox.tsx b/frontend/src/components/Checkbox/Checkbox.tsx index 9b2a3b48a4..ef260d1c71 100644 --- a/frontend/src/components/Checkbox/Checkbox.tsx +++ b/frontend/src/components/Checkbox/Checkbox.tsx @@ -113,7 +113,7 @@ const OthersCheckbox = forwardRef((props, ref) => { {...props} onChange={handleCheckboxChange} > - {t('features.adminForm.sidebar.fields.radio.others')} + {t('features.publicForm.components.fields.option.others')} ) }) diff --git a/frontend/src/components/Dropdown/SingleSelect/SingleSelectProvider.tsx b/frontend/src/components/Dropdown/SingleSelect/SingleSelectProvider.tsx index 3ecec34a68..6f895e7a7d 100644 --- a/frontend/src/components/Dropdown/SingleSelect/SingleSelectProvider.tsx +++ b/frontend/src/components/Dropdown/SingleSelect/SingleSelectProvider.tsx @@ -51,7 +51,6 @@ export const SingleSelectProvider = ({ onChange, name, filter = defaultFilter, - nothingFoundLabel = 'No matching results', placeholder: placeholderProp, clearButtonLabel = 'Clear selection', isClearable = true, @@ -83,9 +82,16 @@ export const SingleSelectProvider = ({ const placeholder = useMemo(() => { if (placeholderProp === null) return '' - return placeholderProp ?? t('features.common.dropdown.placeholder') + return ( + placeholderProp ?? + t('features.publicForm.components.fields.dropdown.placeholder') + ) }, [placeholderProp, t]) + const nothingFoundLabel = t( + 'features.publicForm.components.fields.dropdown.nothingFound', + ) + const getFilteredItems = useCallback( (filterValue?: string) => filterValue ? filter(items, filterValue) : items, diff --git a/frontend/src/components/Field/Attachment/Attachment.tsx b/frontend/src/components/Field/Attachment/Attachment.tsx index 5bb3acce6d..fdca54a483 100644 --- a/frontend/src/components/Field/Attachment/Attachment.tsx +++ b/frontend/src/components/Field/Attachment/Attachment.tsx @@ -148,14 +148,14 @@ export const Attachment = forwardRef( case 'file-invalid-type': { const fileExt = getFileExtension(rejectedFiles[0].file.name) errorMessage = t( - `features.adminForm.sidebar.fields.imageAttachment.error.fileInvalidType`, + `features.publicForm.components.fields.attachment.error.fileInvalidType`, { fileExt }, ) break } case 'too-many-files': { errorMessage = t( - `features.adminForm.sidebar.fields.imageAttachment.error.tooManyFiles`, + `features.publicForm.components.fields.attachment.error.tooManyFiles`, ) break } @@ -178,7 +178,7 @@ export const Attachment = forwardRef( const stringOfInvalidExtensions = invalidFilesInZip.join(', ') return onError?.( t( - 'features.adminForm.sidebar.fields.imageAttachment.error.zipFileInvalidType', + 'features.publicForm.components.fields.attachment.error.zipFileInvalidType', { stringOfInvalidExtensions }, ), ) @@ -186,7 +186,7 @@ export const Attachment = forwardRef( } catch { return onError?.( t( - 'features.adminForm.sidebar.fields.imageAttachment.error.zipParsing', + 'features.publicForm.components.fields.attachment.error.zipParsing', ), ) } @@ -224,7 +224,7 @@ export const Attachment = forwardRef( return { code: 'file-too-large', message: t( - 'features.adminForm.sidebar.fields.imageAttachment.error.fileTooLarge', + 'features.publicForm.components.fields.attachment.error.fileTooLarge', { readableMaxSize }, ), } @@ -233,7 +233,7 @@ export const Attachment = forwardRef( return { code: 'file-empty', message: t( - 'features.adminForm.sidebar.fields.imageAttachment.error.zipParsing', + 'features.publicForm.components.fields.attachment.error.zipParsing', ), } } @@ -336,7 +336,7 @@ export const Attachment = forwardRef( aria-hidden > {t( - 'features.adminForm.sidebar.fields.imageAttachment.maxFileSize', + 'features.publicForm.components.fields.attachment.maxFileSize', { readableMaxSize, }, diff --git a/frontend/src/components/Field/Attachment/AttachmentDropzone.tsx b/frontend/src/components/Field/Attachment/AttachmentDropzone.tsx index cae8781b27..f1042b2581 100644 --- a/frontend/src/components/Field/Attachment/AttachmentDropzone.tsx +++ b/frontend/src/components/Field/Attachment/AttachmentDropzone.tsx @@ -37,10 +37,10 @@ export const AttachmentDropzone = ({ {t( - 'features.adminForm.sidebar.fields.imageAttachment.fileUploaderLink', + 'features.publicForm.components.fields.attachment.fileUploaderLink', )} - {t('features.adminForm.sidebar.fields.imageAttachment.dragAndDrop')} + {t('features.publicForm.components.fields.attachment.dragAndDrop')} )} diff --git a/frontend/src/components/Field/Attachment/AttachmentFileInfo.tsx b/frontend/src/components/Field/Attachment/AttachmentFileInfo.tsx index 919023f03b..1519dbed05 100644 --- a/frontend/src/components/Field/Attachment/AttachmentFileInfo.tsx +++ b/frontend/src/components/Field/Attachment/AttachmentFileInfo.tsx @@ -57,7 +57,7 @@ export const AttachmentFileInfo = ({ variant="clear" colorScheme="danger" aria-label={t( - 'features.adminForm.sidebar.fields.imageAttachment.ariaLabelRemove', + 'features.publicForm.components.fields.attachment.ariaLabelRemove', )} icon={} onClick={handleRemoveFile} diff --git a/frontend/src/components/Field/YesNo/YesNo.tsx b/frontend/src/components/Field/YesNo/YesNo.tsx index c02d906e15..b89ecab914 100644 --- a/frontend/src/components/Field/YesNo/YesNo.tsx +++ b/frontend/src/components/Field/YesNo/YesNo.tsx @@ -88,7 +88,7 @@ export const YesNo = forwardRef( {...noProps} onChange={(value) => onChange(value as YesNoOptionValue)} leftIcon={BiX} - label={t('features.adminForm.sidebar.fields.yesNo.no')} + label={t('features.publicForm.components.fields.yesNo.no')} // Ref is set here for tracking current value, and also so any errors // can focus this input. ref={ref} @@ -100,7 +100,7 @@ export const YesNo = forwardRef( {...yesProps} onChange={(value) => onChange(value as YesNoOptionValue)} leftIcon={BiCheck} - label={t('features.adminForm.sidebar.fields.yesNo.yes')} + label={t('features.publicForm.components.fields.yesNo.yes')} title={props.title} /> diff --git a/frontend/src/components/FormEndPage/EndPageBlock.tsx b/frontend/src/components/FormEndPage/EndPageBlock.tsx index d141ce524b..012615362f 100644 --- a/frontend/src/components/FormEndPage/EndPageBlock.tsx +++ b/frontend/src/components/FormEndPage/EndPageBlock.tsx @@ -1,10 +1,12 @@ import { useEffect, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' import { Box, Text, VisuallyHidden } from '@chakra-ui/react' import { format } from 'date-fns' -import { FormColorTheme, FormDto } from '~shared/types/form' +import { FormColorTheme, FormDto, Language } from '~shared/types/form' import { useMdComponents } from '~hooks/useMdComponents' +import { getValueInSelectedLanguage } from '~utils/multiLanguage' import Button from '~components/Button' import { MarkdownText } from '~components/MarkdownText' @@ -27,6 +29,7 @@ export const EndPageBlock = ({ focusOnMount, isButtonHidden, }: EndPageBlockProps): JSX.Element => { + const { i18n } = useTranslation() const focusRef = useRef(null) useEffect(() => { if (focusOnMount) { @@ -43,6 +46,20 @@ export const EndPageBlock = ({ }, }) + const selectedLanguage = i18n.language as Language + + const title = getValueInSelectedLanguage({ + defaultValue: endPage.title, + translations: endPage.titleTranslations, + selectedLanguage, + }) + + const paragraph = getValueInSelectedLanguage({ + defaultValue: endPage.paragraph ?? '', + translations: endPage.paragraphTranslations, + selectedLanguage, + }) + const submissionTimestamp = useMemo( () => format(new Date(submissionData.timestamp), 'dd MMM yyyy, HH:mm:ss z'), [submissionData.timestamp], @@ -62,13 +79,11 @@ export const EndPageBlock = ({ {submittedAriaText} - {endPage.title} + {title} - {endPage.paragraph ? ( + {paragraph ? ( - - {endPage.paragraph} - + {paragraph} ) : null} diff --git a/frontend/src/components/Radio/Radio.tsx b/frontend/src/components/Radio/Radio.tsx index 93f70a5b6e..c199704ca8 100644 --- a/frontend/src/components/Radio/Radio.tsx +++ b/frontend/src/components/Radio/Radio.tsx @@ -283,7 +283,7 @@ const OthersRadio = forwardRef((props, ref) => { // Required should apply to radio group rather than individual radio. isRequired={false} > - {t('features.adminForm.sidebar.fields.radio.others')} + {t('features.publicForm.components.fields.option.others')} ) }) diff --git a/frontend/src/constants/validation.ts b/frontend/src/constants/validation.ts index 7ebdb476f1..0bcda64011 100644 --- a/frontend/src/constants/validation.ts +++ b/frontend/src/constants/validation.ts @@ -1,8 +1,6 @@ export const REQUIRED_ERROR = 'This field is required' export const INVALID_EMAIL_ERROR = 'Please enter a valid email' -export const INVALID_EMAIL_DOMAIN_ERROR = - 'The entered email does not belong to an allowed email domain' export const INVALID_DROPDOWN_OPTION_ERROR = 'Entered value is not a valid dropdown option' diff --git a/frontend/src/features/admin-form/create/CreatePage.tsx b/frontend/src/features/admin-form/create/CreatePage.tsx index 7a3a0fb517..998d273d6a 100644 --- a/frontend/src/features/admin-form/create/CreatePage.tsx +++ b/frontend/src/features/admin-form/create/CreatePage.tsx @@ -1,7 +1,10 @@ import { useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' import { useNavigate, useParams } from 'react-router-dom' import { Flex } from '@chakra-ui/react' +import { Language } from '~shared/types' + import { FEATURE_TOUR_KEY_PREFIX } from '~constants/localStorage' import { ADMINFORM_RESULTS_SUBROUTE, ADMINFORM_ROUTE } from '~constants/routes' import { useLocalStorage } from '~hooks/useLocalStorage' @@ -28,12 +31,16 @@ export const CreatePage = (): JSX.Element => { const { hasEditAccess, isLoading: isCollabLoading } = useAdminFormCollaborators(formId) const navigate = useNavigate() + const { i18n } = useTranslation() // Redirect view-only collaborators to results screen. useEffect(() => { + // Always default language key back to English + i18n.changeLanguage(Language.ENGLISH) + if (!isCollabLoading && !hasEditAccess) navigate(`${ADMINFORM_ROUTE}/${formId}/${ADMINFORM_RESULTS_SUBROUTE}`) - }, [formId, hasEditAccess, isCollabLoading, navigate]) + }, [formId, hasEditAccess, i18n, isCollabLoading, navigate]) const { user, isLoading } = useUser() const localStorageFeatureTourKey = useMemo(() => { diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditCheckbox/EditCheckbox.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditCheckbox/EditCheckbox.tsx index 22ba191a9b..2717cc3859 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditCheckbox/EditCheckbox.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditCheckbox/EditCheckbox.tsx @@ -234,7 +234,7 @@ export const EditCheckbox = ({ field }: EditCheckboxProps): JSX.Element => { { { const baseMeta: Pick< MyInfoField, - 'disabled' | 'required' | 'title' | 'description' | 'fieldType' | 'myInfo' + | 'disabled' + | 'required' + | 'title' + | 'description' + | 'fieldType' + | 'myInfo' + | 'titleTranslations' + | 'descriptionTranslations' > = { disabled: false, required: true, title: MYINFO_ATTRIBUTE_MAP[myInfoAttribute].value, + titleTranslations: MYINFO_ATTRIBUTE_MAP[myInfoAttribute]?.titleTranslations, description: '', fieldType: MYINFO_ATTRIBUTE_MAP[myInfoAttribute].fieldType, myInfo: { diff --git a/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditCondition/EditConditionBlock.tsx b/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditCondition/EditConditionBlock.tsx index 33b04ecff3..5874f3165d 100644 --- a/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditCondition/EditConditionBlock.tsx +++ b/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditCondition/EditConditionBlock.tsx @@ -156,14 +156,14 @@ export const EditConditionBlock = ({ switch (mappedField.fieldType) { case BasicField.YesNo: return [ - t('features.adminForm.sidebar.fields.yesNo.yes'), - t('features.adminForm.sidebar.fields.yesNo.no'), + t('features.publicForm.components.fields.yesNo.yes'), + t('features.publicForm.components.fields.yesNo.no'), ] case BasicField.Radio: if (mappedField.othersRadioButton) { // 'Others' does not show up in fieldOptions return mappedField.fieldOptions.concat( - t('features.adminForm.sidebar.fields.radio.others'), + t('features.publicForm.components.fields.option.others'), ) } return mappedField.fieldOptions diff --git a/frontend/src/features/admin-form/create/logic/types.ts b/frontend/src/features/admin-form/create/logic/types.ts index e0a58524e6..f947900eb5 100644 --- a/frontend/src/features/admin-form/create/logic/types.ts +++ b/frontend/src/features/admin-form/create/logic/types.ts @@ -12,4 +12,5 @@ export enum AdminEditLogicState { export type EditLogicInputs = FormLogic & { preventSubmitMessage?: PreventSubmitLogic['preventSubmitMessage'] show?: ShowFieldLogic['show'] + preventSubmitMessageTranslations?: PreventSubmitLogic['preventSubmitMessageTranslations'] } diff --git a/frontend/src/features/admin-form/preview/PreviewFormPage.tsx b/frontend/src/features/admin-form/preview/PreviewFormPage.tsx index da699017ae..ff24ce584a 100644 --- a/frontend/src/features/admin-form/preview/PreviewFormPage.tsx +++ b/frontend/src/features/admin-form/preview/PreviewFormPage.tsx @@ -13,6 +13,7 @@ import { FormFooter } from '~features/public-form/components/FormFooter' import FormInstructions from '~features/public-form/components/FormInstructions' import { PublicFormLogo } from '~features/public-form/components/FormLogo' import FormStartPage from '~features/public-form/components/FormStartPage' +import LanguageControl from '~features/public-form/components/LanguageControl' import { PublicFormWrapper } from '~features/public-form/components/PublicFormWrapper' import { PreviewFormBannerContainer } from '../common/components/PreviewFormBanner' @@ -31,6 +32,7 @@ export const PreviewFormPage = (): JSX.Element => { + diff --git a/frontend/src/features/admin-form/settings/SettingsMultiLangPage.tsx b/frontend/src/features/admin-form/settings/SettingsMultiLangPage.tsx index f111972200..d35926afb0 100644 --- a/frontend/src/features/admin-form/settings/SettingsMultiLangPage.tsx +++ b/frontend/src/features/admin-form/settings/SettingsMultiLangPage.tsx @@ -14,6 +14,7 @@ export const SettingsMultiLangPage = (): JSX.Element => { const isTranslationInput = !_.isNull(translationInput) const isEndPageTranslationInput = translationInput === 'endPage' const isStartPageTransltionInput = translationInput === 'startPage' + const isFormLogicTranslationInput = translationInput === 'formLogic' // Request user to select a language if (!unicodeLocale) { @@ -23,16 +24,21 @@ export const SettingsMultiLangPage = (): JSX.Element => { if (!isTranslationInput) { return } + + const formFieldToBeTranslated = + isEndPageTranslationInput || + isStartPageTransltionInput || + isFormLogicTranslationInput + ? -1 + : _.toNumber(translationInput) + return ( ) } diff --git a/frontend/src/features/admin-form/settings/SettingsPage.tsx b/frontend/src/features/admin-form/settings/SettingsPage.tsx index 64419d6325..59aba732b3 100644 --- a/frontend/src/features/admin-form/settings/SettingsPage.tsx +++ b/frontend/src/features/admin-form/settings/SettingsPage.tsx @@ -17,9 +17,12 @@ import { LanguageTranslation } from '~assets/icons/LanguageTranslation' import { ADMINFORM_RESULTS_SUBROUTE, ADMINFORM_ROUTE } from '~constants/routes' import { useDraggable } from '~hooks/useDraggable' +import { useUser } from '~features/user/queries' + import { useAdminFormCollaborators } from '../common/queries' import { SettingsTab } from './components/SettingsTab' +import { useAdminFormSettings } from './queries' import { SettingsAuthPage } from './SettingsAuthPage' import { SettingsEmailsPage } from './SettingsEmailsPage' import { SettingsGeneralPage } from './SettingsGeneralPage' @@ -37,6 +40,8 @@ interface TabEntry { export const SettingsPage = (): JSX.Element => { const { formId, settingsTab } = useParams() + const { isLoading: isFormSettingLoading } = useAdminFormSettings() + const { user, isLoading: isUserLoading } = useUser() const { t } = useTranslation() if (!formId) throw new Error('No formId provided') @@ -51,6 +56,18 @@ export const SettingsPage = (): JSX.Element => { navigate(`${ADMINFORM_ROUTE}/${formId}/${ADMINFORM_RESULTS_SUBROUTE}`) }, [formId, hasEditAccess, isCollabLoading, navigate]) + const multiLangTab = + !isUserLoading && + !isFormSettingLoading && + user?.betaFlags?.multiLangTranslation + ? { + label: 'Multi-language', + icon: LanguageTranslation, + component: SettingsMultiLangPage, + path: 'language', + } + : null + const tabConfig: TabEntry[] = [ { label: t('features.adminForm.settings.general.title'), @@ -83,12 +100,7 @@ export const SettingsPage = (): JSX.Element => { component: SettingsPaymentsPage, path: 'payments', }, - { - label: 'Multi-language', - icon: LanguageTranslation, - component: SettingsMultiLangPage, - path: 'language', - }, + multiLangTab, ].filter(Boolean) as TabEntry[] const { ref, onMouseDown } = useDraggable() diff --git a/frontend/src/features/admin-form/settings/components/MultiLanguageSection/EndPageTranslationContainer.tsx b/frontend/src/features/admin-form/settings/components/MultiLanguageSection/EndPageTranslationContainer.tsx index 571c3bf629..04fb1d83c1 100644 --- a/frontend/src/features/admin-form/settings/components/MultiLanguageSection/EndPageTranslationContainer.tsx +++ b/frontend/src/features/admin-form/settings/components/MultiLanguageSection/EndPageTranslationContainer.tsx @@ -1,5 +1,5 @@ -import React from 'react' import { Divider, Flex, Text } from '@chakra-ui/react' +import _ from 'lodash' import { FormEndPage, Language } from '~shared/types' @@ -18,7 +18,7 @@ export const EndPageTranslationsContainer = ({ }: EndPageTranslationsContainerProps) => { if (!endPage) return null - const hasParagraph = endPage.paragraph?.trim() !== '' + const hasParagraph = !_.isEmpty(endPage.paragraph?.trim()) const currentTitleTranslations = endPage.titleTranslations ?? [] const currentParagraphTranslations = endPage.paragraphTranslations ?? [] @@ -51,24 +51,26 @@ export const EndPageTranslationsContainer = ({ previousTranslation={previousTitleTranslation} /> - {hasParagraph && ( - - - Follow-up instructions - - - + <> + + + + Follow-up instructions + + + + )} ) diff --git a/frontend/src/features/admin-form/settings/components/MultiLanguageSection/FormFieldTranslationContainer.tsx b/frontend/src/features/admin-form/settings/components/MultiLanguageSection/FormFieldTranslationContainer.tsx index d5ef55c531..4dd95fd354 100644 --- a/frontend/src/features/admin-form/settings/components/MultiLanguageSection/FormFieldTranslationContainer.tsx +++ b/frontend/src/features/admin-form/settings/components/MultiLanguageSection/FormFieldTranslationContainer.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import { FormState } from 'react-hook-form' import { Divider, Flex, Text } from '@chakra-ui/react' import { FormField, Language } from '~shared/types' @@ -7,17 +7,20 @@ import { BasicField } from '~shared/types/field' import { OptionsTranslationContainer } from './OptionsTranslationContainer' import { TableTranslationContainer } from './TableTranslationContainer' import { TranslationContainer } from './TranslationContainer' +import { TranslationInput } from './TranslationSection' interface FormFieldTranslationContainerProps { formFieldData: FormField | undefined capitalisedLanguage: string unicodeLocale: Language + formState: FormState } export const FormFieldTranslationContainer = ({ formFieldData, capitalisedLanguage, unicodeLocale, + formState, }: FormFieldTranslationContainerProps) => { if (!formFieldData) return null @@ -86,6 +89,7 @@ export const FormFieldTranslationContainer = ({ unicodeLocale={unicodeLocale} language={capitalisedLanguage} formFieldData={formFieldData} + errors={formState.errors.fieldOptionsTranslations} /> )} @@ -96,6 +100,7 @@ export const FormFieldTranslationContainer = ({ unicodeLocale={unicodeLocale} language={capitalisedLanguage} columns={formFieldData.columns} + errors={formState.errors.tableColumnDropdownTranslations} /> )} diff --git a/frontend/src/features/admin-form/settings/components/MultiLanguageSection/FormLogicTranslationContainer.tsx b/frontend/src/features/admin-form/settings/components/MultiLanguageSection/FormLogicTranslationContainer.tsx new file mode 100644 index 0000000000..88fbb0a400 --- /dev/null +++ b/frontend/src/features/admin-form/settings/components/MultiLanguageSection/FormLogicTranslationContainer.tsx @@ -0,0 +1,85 @@ +import { useFormContext } from 'react-hook-form' +import { Divider, Flex, FormControl, Text } from '@chakra-ui/react' + +import { Language, PreventSubmitLogicDto } from '~shared/types' + +import Textarea from '~components/Textarea' + +import { TranslationInput } from './TranslationSection' + +interface TableTranslationContainerProps { + language: string + unicodeLocale: Language + formLogics: PreventSubmitLogicDto[] +} + +export const FormLogicTranslationContainer = ({ + language, + unicodeLocale, + formLogics, +}: TableTranslationContainerProps) => { + const { register } = useFormContext() + + return ( + <> + {formLogics.map((formLogic, index) => { + const defaultPreventSubmitMessage = formLogic.preventSubmitMessage + const previousPreventSubmissionMessage = + formLogic.preventSubmitMessageTranslations?.find( + (translationMapping) => { + return translationMapping.language === unicodeLocale + }, + )?.translation ?? '' + + return ( + + + Disable Submission + + + + + Default + +