From 00de8e137eb41c808ebc0589d9ed2aa91470bc7d Mon Sep 17 00:00:00 2001 From: Siddarth Nandanahosur Suresh <48427064+siddarth2824@users.noreply.github.com> Date: Mon, 6 Jan 2025 23:59:22 +0800 Subject: [PATCH] feat: multi lang feature (#8022) * feat: add model fields to represent translations for form fields (#7457) * feat: add fields in model to represent translations for form fields * feat: add shared types to represent form field translations * fix: use unicode locales * feat: add settings tab and toggle for multi lang feature (#7561) * feat: add settings tab and toggle for multi lang feature * feat: add fields to joi validations * fix: refactor code to address comments * fix: failing tests * feat: add tooltip over icons * feat: add chromatic tests * feat: add form field list for translation (#7778) * feat: add model fields to represent translations for form fields (#7457) * feat: add fields in model to represent translations for form fields * feat: add shared types to represent form field translations * fix: use unicode locales * feat: add settings tab and toggle for multi lang feature (#7561) * feat: add settings tab and toggle for multi lang feature * feat: add fields to joi validations * fix: refactor code to address comments * fix: failing tests * feat: add tooltip over icons * feat: add chromatic tests * feat: add view to list form fields for translation * feat: update logic for checking completion of translations and add storybook tests * chore: uncomment growthbook middleware usage * chore: update storybook function used * chore: address comments * chore: update app router * feat: add view for translation input (#7895) * feat: add view for translation input * fix: refactor code and remove console log statements * chore: clean up code * chore: fix alignment of translation input and header * chore: address comments * feat: add translations to public form (#7976) * feat: add model fields to represent translations for form fields (#7457) * feat: add fields in model to represent translations for form fields * feat: add shared types to represent form field translations * fix: use unicode locales * feat: add settings tab and toggle for multi lang feature (#7561) * feat: add settings tab and toggle for multi lang feature * feat: add fields to joi validations * fix: refactor code to address comments * fix: failing tests * feat: add tooltip over icons * feat: add chromatic tests * feat: add translations on public form * fix: missing provider values for preview and template providers * feat: add fixed translations for yes and no field * chore: fix code * chore: remove unused code and add import * feat: add fixed translations for est time taken string * chore: remove unnecessary loggin * feat: add translations for verifiable fields * chore: fix tests * feat: add error if user adds less than required translations for form fields with options * feat: add beta flag for multi language translation feature for admins * chore: refactor code * feat: add fixed translations for default placeholders on public form * feat: add fixed translations for not found label * feat: add translations for maximum file size label * feat: add translations for others label * feat: add fixed translations for add row label * feat: add translation for prevent submission messages * fix: make prevent submission translations optional * fix: tests * refactor: use i18next to toggle languages Use i18next to hold currently selected language rather than use our own internal state flag in PublicFormContext. That way, a form submitter can control the language used across the entire Form service. - Rework LanguageControl and PublicFormContext to use i18next to hold currently selected language, falling back on `Language.ENGLISH` - Rework components to lookup current lang from `useTranslation()`, not `PublicFormContext.selectedPublicFormLanguage` or prop passing * fix: stub i18next for YesNo,Email,Verify fields * refactor(i18n): move translations from `fixedTranslations` * refactor(i18n): extract hard-coded translations * feat: add title translations for MyInfo fields * feat: always default language back to English on CreatePage * fix: update myInfo translations and address comments * feat: add custom header to track user selected form language --------- Co-authored-by: LoneRifle --------- Co-authored-by: LoneRifle --- .../backend/helpers/generate-form-data.ts | 6 + .../src/assets/icons/LanguageTranslation.tsx | 16 + frontend/src/components/Checkbox/Checkbox.tsx | 2 +- .../SingleSelect/SingleSelectProvider.tsx | 10 +- .../Field/Attachment/Attachment.tsx | 14 +- .../Field/Attachment/AttachmentDropzone.tsx | 4 +- .../Field/Attachment/AttachmentFileInfo.tsx | 2 +- frontend/src/components/Field/YesNo/YesNo.tsx | 4 +- .../components/FormEndPage/EndPageBlock.tsx | 27 +- frontend/src/components/Radio/Radio.tsx | 2 +- frontend/src/constants/routes.ts | 4 + frontend/src/constants/validation.ts | 2 - .../features/admin-form/create/CreatePage.tsx | 9 +- .../EditCheckbox/EditCheckbox.tsx | 2 +- .../edit-fieldtype/EditRadio/EditRadio.tsx | 2 +- .../builder-and-design/utils/fieldCreation.ts | 10 +- .../EditCondition/EditConditionBlock.tsx | 6 +- .../features/admin-form/create/logic/types.ts | 1 + .../admin-form/preview/PreviewFormPage.tsx | 2 + .../SettingsMultiLangPage.stories.tsx | 165 +++++++ .../settings/SettingsMultiLangPage.tsx | 44 ++ .../admin-form/settings/SettingsPage.tsx | 20 + .../admin-form/settings/SettingsService.ts | 17 + .../EndPageTranslationContainer.tsx | 77 ++++ .../FormFieldTranslationContainer.tsx | 109 +++++ .../FormLogicTranslationContainer.tsx | 85 ++++ .../FormMultiLanguageToggle.tsx | 209 +++++++++ .../MultiLanguageSection.tsx | 12 + .../OptionsTranslationContainer.tsx | 76 ++++ .../StartPageTranslationContainer.tsx | 40 ++ .../TableTranslationContainer.tsx | 149 +++++++ .../TranslationContainer.tsx | 54 +++ .../TranslationListSection.tsx | 410 ++++++++++++++++++ .../TranslationSection.tsx | 130 ++++++ .../mutations/useTranslationLogic.ts | 284 ++++++++++++ .../utils/translationUtils.ts | 239 ++++++++++ .../features/admin-form/settings/mutations.ts | 53 +++ .../features/public-form/PublicFormPage.tsx | 2 + .../public-form/PublicFormProvider.tsx | 5 +- .../features/public-form/PublicFormService.ts | 16 + .../FormFields/FormSectionsContext.tsx | 13 +- .../FormFields/PublicFormSubmitButton.tsx | 13 +- .../FormInstructions/FormInstructions.tsx | 4 +- .../FormInstructionsContainer.tsx | 14 +- .../FormStartPage/FormStartPage.tsx | 4 +- .../FormStartPage/useFormHeader.tsx | 12 +- .../LanguageControl/LanguageControl.tsx | 119 +++++ .../components/LanguageControl/index.ts | 1 + .../components/SectionSidebar/SidebarLink.tsx | 15 +- .../Email/VerifiableEmailField.tsx | 10 +- .../VerifiableFieldContainer.tsx | 9 +- .../VerificationBox/VerificationBox.tsx | 25 +- .../components/VerificationBox/constants.ts | 32 -- .../admin-form/sidebar/fields/en-sg.ts | 22 - .../admin-form/sidebar/fields/index.ts | 18 - .../src/i18n/locales/features/common/en-sg.ts | 3 - .../src/i18n/locales/features/common/index.ts | 3 - .../locales/features/public-form/en-sg.ts | 9 + .../features/public-form/fields/en-sg.ts | 60 +++ .../features/public-form/fields/index.ts | 56 +++ .../features/public-form/fields/ms-sg.ts | 46 ++ .../features/public-form/fields/ta-sg.ts | 46 ++ .../features/public-form/fields/zh-sg.ts | 43 ++ .../locales/features/public-form/index.ts | 12 + .../locales/features/public-form/ms-sg.ts | 19 + .../locales/features/public-form/ta-sg.ts | 20 + .../features/public-form/table/index.ts | 4 + .../features/public-form/table/ms-sg.ts | 7 + .../features/public-form/table/ta-sg.ts | 7 + .../features/public-form/table/zh-sg.ts | 7 + .../locales/features/public-form/zh-sg.ts | 19 + frontend/src/i18n/locales/index.ts | 10 +- frontend/src/i18n/locales/ms-sg.ts | 12 + frontend/src/i18n/locales/ta-sg.ts | 12 + frontend/src/i18n/locales/types.ts | 4 +- frontend/src/i18n/locales/zh-sg.ts | 2 + .../src/mocks/msw/handlers/admin-form/form.ts | 257 +++++++++++ .../Field/Checkbox/CheckboxField.tsx | 24 +- .../Field/Dropdown/DropdownField.tsx | 44 +- .../templates/Field/Email/EmailField.test.tsx | 11 +- .../templates/Field/Email/EmailFieldInput.tsx | 18 +- .../src/templates/Field/FieldContainer.tsx | 30 +- .../src/templates/Field/Image/ImageField.tsx | 17 +- .../Field/Paragraph/ParagraphField.tsx | 14 +- .../src/templates/Field/Radio/RadioField.tsx | 21 +- .../templates/Field/Section/SectionField.tsx | 25 +- .../src/templates/Field/Table/ColumnCell.tsx | 43 +- .../src/templates/Field/Table/TableField.tsx | 32 +- .../Field/Table/TableFieldContainer.tsx | 23 +- frontend/src/utils/fieldValidation.ts | 26 +- frontend/src/utils/multiLanguage.ts | 83 ++++ frontend/src/utils/storybook.tsx | 8 +- shared/constants/field/myinfo/index.ts | 123 ++++++ shared/constants/form.ts | 4 + shared/types/field/base.ts | 9 + shared/types/field/checkboxField.ts | 2 + shared/types/field/dropdownField.ts | 2 + shared/types/field/radioField.ts | 2 + shared/types/field/tableField.ts | 3 +- shared/types/form/form.ts | 29 +- shared/types/form/form_logic.ts | 3 +- shared/types/user.ts | 1 + .../__tests__/form.server.model.spec.ts | 15 +- src/app/models/field/baseField.ts | 32 +- src/app/models/field/checkboxField.ts | 13 + src/app/models/field/dropdownField.ts | 13 + src/app/models/field/radioField.ts | 13 + src/app/models/field/tableField.ts | 16 +- src/app/models/form.server.model.ts | 60 +++ src/app/models/form_logic.server.schema.ts | 16 + src/app/models/user.server.model.ts | 1 + .../form/admin-form/admin-form.controller.ts | 51 +++ .../form/admin-form/admin-form.middlewares.ts | 5 + .../__tests__/admin-forms.form.routes.spec.ts | 1 + 114 files changed, 3929 insertions(+), 194 deletions(-) create mode 100644 frontend/src/assets/icons/LanguageTranslation.tsx create mode 100644 frontend/src/features/admin-form/settings/SettingsMultiLangPage.stories.tsx create mode 100644 frontend/src/features/admin-form/settings/SettingsMultiLangPage.tsx create mode 100644 frontend/src/features/admin-form/settings/components/MultiLanguageSection/EndPageTranslationContainer.tsx create mode 100644 frontend/src/features/admin-form/settings/components/MultiLanguageSection/FormFieldTranslationContainer.tsx create mode 100644 frontend/src/features/admin-form/settings/components/MultiLanguageSection/FormLogicTranslationContainer.tsx create mode 100644 frontend/src/features/admin-form/settings/components/MultiLanguageSection/FormMultiLanguageToggle.tsx create mode 100644 frontend/src/features/admin-form/settings/components/MultiLanguageSection/MultiLanguageSection.tsx create mode 100644 frontend/src/features/admin-form/settings/components/MultiLanguageSection/OptionsTranslationContainer.tsx create mode 100644 frontend/src/features/admin-form/settings/components/MultiLanguageSection/StartPageTranslationContainer.tsx create mode 100644 frontend/src/features/admin-form/settings/components/MultiLanguageSection/TableTranslationContainer.tsx create mode 100644 frontend/src/features/admin-form/settings/components/MultiLanguageSection/TranslationContainer.tsx create mode 100644 frontend/src/features/admin-form/settings/components/MultiLanguageSection/TranslationListSection.tsx create mode 100644 frontend/src/features/admin-form/settings/components/MultiLanguageSection/TranslationSection.tsx create mode 100644 frontend/src/features/admin-form/settings/components/MultiLanguageSection/mutations/useTranslationLogic.ts create mode 100644 frontend/src/features/admin-form/settings/components/MultiLanguageSection/utils/translationUtils.ts create mode 100644 frontend/src/features/public-form/components/LanguageControl/LanguageControl.tsx create mode 100644 frontend/src/features/public-form/components/LanguageControl/index.ts delete mode 100644 frontend/src/features/verifiable-fields/components/VerificationBox/constants.ts create mode 100644 frontend/src/i18n/locales/features/public-form/fields/en-sg.ts create mode 100644 frontend/src/i18n/locales/features/public-form/fields/index.ts create mode 100644 frontend/src/i18n/locales/features/public-form/fields/ms-sg.ts create mode 100644 frontend/src/i18n/locales/features/public-form/fields/ta-sg.ts create mode 100644 frontend/src/i18n/locales/features/public-form/fields/zh-sg.ts create mode 100644 frontend/src/i18n/locales/features/public-form/ms-sg.ts create mode 100644 frontend/src/i18n/locales/features/public-form/ta-sg.ts create mode 100644 frontend/src/i18n/locales/features/public-form/table/ms-sg.ts create mode 100644 frontend/src/i18n/locales/features/public-form/table/ta-sg.ts create mode 100644 frontend/src/i18n/locales/features/public-form/table/zh-sg.ts create mode 100644 frontend/src/i18n/locales/features/public-form/zh-sg.ts create mode 100644 frontend/src/i18n/locales/ms-sg.ts create mode 100644 frontend/src/i18n/locales/ta-sg.ts create mode 100644 frontend/src/utils/multiLanguage.ts diff --git a/__tests__/unit/backend/helpers/generate-form-data.ts b/__tests__/unit/backend/helpers/generate-form-data.ts index ef0b5793f1..2730e9b732 100644 --- a/__tests__/unit/backend/helpers/generate-form-data.ts +++ b/__tests__/unit/backend/helpers/generate-form-data.ts @@ -76,6 +76,8 @@ export const generateDefaultField = ( fieldType, required: true, disabled: false, + titleTranslations: [], + descriptionTranslations: [], } switch (fieldType) { case BasicField.Table: @@ -102,6 +104,7 @@ export const generateDefaultField = ( return { ...defaultParams, fieldOptions: ['Option 1', 'Option 2'], + fieldOptionsTranslations: [], getQuestion: () => defaultParams.title, ValidationOptions: { customMin: null, @@ -141,6 +144,7 @@ export const generateDefaultField = ( return { ...defaultParams, fieldOptions: ['Option 1', 'Option 2'], + fieldOptionsTranslations: [], getQuestion: () => defaultParams.title, ...customParams, } as IDropdownFieldSchema @@ -401,6 +405,7 @@ export const generateTableDropdownColumn = ( required: true, _id: new ObjectId().toHexString(), fieldOptions: ['a', 'b', 'c'], + fieldOptionsTranslations: [], ...customParams, toObject() { // mock toObject method of mongoose document @@ -410,6 +415,7 @@ export const generateTableDropdownColumn = ( required: true, _id: new ObjectId().toHexString(), fieldOptions: ['a', 'b', 'c'], + fieldOptionsTranslations: [], ...customParams, } }, diff --git a/frontend/src/assets/icons/LanguageTranslation.tsx b/frontend/src/assets/icons/LanguageTranslation.tsx new file mode 100644 index 0000000000..0ca78f0542 --- /dev/null +++ b/frontend/src/assets/icons/LanguageTranslation.tsx @@ -0,0 +1,16 @@ +export const LanguageTranslation = ( + props: React.SVGProps, +): JSX.Element => ( + + + + +) 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/routes.ts b/frontend/src/constants/routes.ts index 13cb2ecf10..6bc90ccad7 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -54,3 +54,7 @@ export const ACTIVE_ADMINFORM_RESULTS_ROUTE_REGEX = new RegExp( export const PAYMENT_PAGE_SUBROUTE = 'payment/:paymentId' export const EDIT_SUBMISSION_PAGE_SUBROUTE = 'edit/:submissionId' + +// Search param keys for multi-language +export const UNICODE_LOCALE = 'unicodeLocale' +export const TRANSLATION_INPUT = 'translationInput' 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.stories.tsx b/frontend/src/features/admin-form/settings/SettingsMultiLangPage.stories.tsx new file mode 100644 index 0000000000..ff35d76be7 --- /dev/null +++ b/frontend/src/features/admin-form/settings/SettingsMultiLangPage.stories.tsx @@ -0,0 +1,165 @@ +import { Meta, StoryFn } from '@storybook/react' + +import { + FormColorTheme, + FormLogoState, + FormSettings, + Language, +} from '~shared/types' + +import { + createFormBuilderMocks, + getAdminFormSettings, + MOCK_FORM_FIELDS_WITH_NO_TRANSLATIONS, + MOCK_FORM_FIELDS_WITH_TRANSLATIONS, + patchAdminFormSettings, +} from '~/mocks/msw/handlers/admin-form' + +import { + getMobileViewParameters, + StoryRouter, + viewports, +} from '~utils/storybook' + +import { SettingsMultiLangPage } from './SettingsMultiLangPage' + +const buildMswRoutes = ({ + overrides, + delay, +}: { + overrides?: Partial + delay?: number | 'infinite' +} = {}) => [ + getAdminFormSettings({ overrides, delay }), + patchAdminFormSettings({ overrides }), +] + +export default { + title: 'Pages/AdminFormPage/Settings/MultiLang', + component: SettingsMultiLangPage, + decorators: [ + StoryRouter({ + initialEntries: ['/61540ece3d4a6e50ac0cc6ff'], + path: '/:formId', + }), + ], + parameters: { + // Required so skeleton "animation" does not hide content. + chromatic: { pauseAnimationAtEnd: true }, + msw: buildMswRoutes(), + }, +} as Meta + +const Template: StoryFn = () => + +// Stories related to toggling multi language translation feature on and off +// and choosing which language to enable translations for +export const MultiLangNotSelected = Template.bind({}) +MultiLangNotSelected.parameters = { + msw: buildMswRoutes({ + overrides: { hasMultiLang: false }, + }), +} + +export const MultiLangAllLanguagesSelected = Template.bind({}) +MultiLangAllLanguagesSelected.parameters = { + msw: buildMswRoutes({ + overrides: { hasMultiLang: true }, + }), +} + +export const MultiLangEnglishChineseMalaySelected = Template.bind({}) +MultiLangEnglishChineseMalaySelected.parameters = { + msw: buildMswRoutes({ + overrides: { + hasMultiLang: true, + supportedLanguages: [Language.ENGLISH, Language.CHINESE, Language.MALAY], + }, + }), +} + +// Stories related to displaying list of form fields for translations +export const MultiLangListOfFormFieldsWithNoTranslations = Template.bind({}) +MultiLangListOfFormFieldsWithNoTranslations.parameters = { + router: { + initialEntries: ['/61540ece3d4a6e50ac0cc6ff/settings/language'], + path: '/:formId/settings/language', + }, + msw: [ + ...createFormBuilderMocks({ + form_fields: MOCK_FORM_FIELDS_WITH_NO_TRANSLATIONS, + startPage: { + colorTheme: FormColorTheme.Blue, + logo: { state: FormLogoState.Default }, + paragraph: 'Test start page', + }, + hasMultiLang: true, + supportedLanguages: [Language.ENGLISH, Language.CHINESE, Language.MALAY], + }), + getAdminFormSettings(), + patchAdminFormSettings(), + ], +} + +export const MultiLangListOfFormFieldsWithCompletedTranslations = Template.bind( + {}, +) +MultiLangListOfFormFieldsWithCompletedTranslations.parameters = { + router: { + initialEntries: ['/61540ece3d4a6e50ac0cc6ff/settings/language'], + path: '/:formId/settings/language', + }, + msw: [ + ...createFormBuilderMocks({ + form_fields: MOCK_FORM_FIELDS_WITH_TRANSLATIONS, + // Completed translations for start page + startPage: { + colorTheme: FormColorTheme.Blue, + logo: { state: FormLogoState.Default }, + paragraph: 'Test start page', + paragraphTranslations: [ + { language: Language.CHINESE, translation: 'Fake Translations' }, + ], + }, + // Completed translations for end page + endPage: { + title: 'Thank you for filling out the form.', + titleTranslations: [ + { language: Language.CHINESE, translation: 'Fake Title Translation' }, + ], + paragraph: 'Test end page paragraph', + paragraphTranslations: [ + { + language: Language.CHINESE, + translation: 'Fake Paragraph Translation', + }, + ], + buttonText: 'Submit another form', + paymentTitle: 'payment title', + paymentParagraph: 'payment paragraph', + }, + hasMultiLang: true, + supportedLanguages: [Language.ENGLISH, Language.CHINESE, Language.MALAY], + }), + getAdminFormSettings(), + patchAdminFormSettings(), + ], +} + +export const Loading = Template.bind({}) +Loading.parameters = { + msw: buildMswRoutes({ delay: 'infinite' }), +} + +export const Mobile = Template.bind({}) +Mobile.parameters = { + ...getMobileViewParameters(), +} + +export const Tablet = Template.bind({}) +Tablet.parameters = { + viewport: { + defaultViewport: 'tablet', + }, + chromatic: { viewports: [viewports.md] }, +} diff --git a/frontend/src/features/admin-form/settings/SettingsMultiLangPage.tsx b/frontend/src/features/admin-form/settings/SettingsMultiLangPage.tsx new file mode 100644 index 0000000000..d35926afb0 --- /dev/null +++ b/frontend/src/features/admin-form/settings/SettingsMultiLangPage.tsx @@ -0,0 +1,44 @@ +import { useSearchParams } from 'react-router-dom' +import _ from 'lodash' + +import { TRANSLATION_INPUT, UNICODE_LOCALE } from '~constants/routes' + +import { MultiLanguageSection } from './components/MultiLanguageSection/MultiLanguageSection' +import { TranslationListSection } from './components/MultiLanguageSection/TranslationListSection' +import { TranslationSection } from './components/MultiLanguageSection/TranslationSection' + +export const SettingsMultiLangPage = (): JSX.Element => { + const [searchParams] = useSearchParams() + const unicodeLocale = searchParams.get(UNICODE_LOCALE) + const translationInput = searchParams.get(TRANSLATION_INPUT) + const isTranslationInput = !_.isNull(translationInput) + const isEndPageTranslationInput = translationInput === 'endPage' + const isStartPageTransltionInput = translationInput === 'startPage' + const isFormLogicTranslationInput = translationInput === 'formLogic' + + // Request user to select a language + if (!unicodeLocale) { + return + } + // Request user to select fields to translate + 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 2f2b6593af..59aba732b3 100644 --- a/frontend/src/features/admin-form/settings/SettingsPage.tsx +++ b/frontend/src/features/admin-form/settings/SettingsPage.tsx @@ -13,15 +13,20 @@ import { Tabs, } from '@chakra-ui/react' +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' +import { SettingsMultiLangPage } from './SettingsMultiLangPage' import { SettingsPaymentsPage } from './SettingsPaymentsPage' import { SettingsWebhooksPage } from './SettingsWebhooksPage' @@ -35,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') @@ -49,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'), @@ -81,6 +100,7 @@ export const SettingsPage = (): JSX.Element => { component: SettingsPaymentsPage, path: 'payments', }, + multiLangTab, ].filter(Boolean) as TabEntry[] const { ref, onMouseDown } = useDraggable() diff --git a/frontend/src/features/admin-form/settings/SettingsService.ts b/frontend/src/features/admin-form/settings/SettingsService.ts index ec47e413f9..dbb10843b7 100644 --- a/frontend/src/features/admin-form/settings/SettingsService.ts +++ b/frontend/src/features/admin-form/settings/SettingsService.ts @@ -77,6 +77,23 @@ export const updateFormLimit: UpdateFormFn<'submissionLimit'> = async ( return updateFormSettings(formId, { submissionLimit: newLimit }) } +export const updateFormHasMultiLang: UpdateFormFn<'hasMultiLang'> = async ( + formId, + newHasMultiLang, +) => { + return updateFormSettings(formId, { + hasMultiLang: newHasMultiLang, + }) +} + +export const updateFormSupportedLanguages: UpdateFormFn< + 'supportedLanguages' +> = async (formId, newSupportedLanguages) => { + return updateFormSettings(formId, { + supportedLanguages: newSupportedLanguages, + }) +} + export const updateFormCaptcha: UpdateFormFn<'hasCaptcha'> = async ( formId, newHasCaptcha, diff --git a/frontend/src/features/admin-form/settings/components/MultiLanguageSection/EndPageTranslationContainer.tsx b/frontend/src/features/admin-form/settings/components/MultiLanguageSection/EndPageTranslationContainer.tsx new file mode 100644 index 0000000000..04fb1d83c1 --- /dev/null +++ b/frontend/src/features/admin-form/settings/components/MultiLanguageSection/EndPageTranslationContainer.tsx @@ -0,0 +1,77 @@ +import { Divider, Flex, Text } from '@chakra-ui/react' +import _ from 'lodash' + +import { FormEndPage, Language } from '~shared/types' + +import { TranslationContainer } from './TranslationContainer' + +interface EndPageTranslationsContainerProps { + endPage?: FormEndPage + capitalisedLanguage: string + unicodeLocale: Language +} + +export const EndPageTranslationsContainer = ({ + endPage, + capitalisedLanguage, + unicodeLocale, +}: EndPageTranslationsContainerProps) => { + if (!endPage) return null + + const hasParagraph = !_.isEmpty(endPage.paragraph?.trim()) + + const currentTitleTranslations = endPage.titleTranslations ?? [] + const currentParagraphTranslations = endPage.paragraphTranslations ?? [] + + const previousTitleTranslation = + currentTitleTranslations.find( + (translation) => translation.language === unicodeLocale, + )?.translation ?? '' + + const previousParagraphTranslation = + currentParagraphTranslations.find( + (translation) => translation.language === unicodeLocale, + )?.translation ?? '' + + return ( + <> + + + Question + + + + {hasParagraph && ( + <> + + + + 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 new file mode 100644 index 0000000000..4dd95fd354 --- /dev/null +++ b/frontend/src/features/admin-form/settings/components/MultiLanguageSection/FormFieldTranslationContainer.tsx @@ -0,0 +1,109 @@ +import { FormState } from 'react-hook-form' +import { Divider, Flex, Text } from '@chakra-ui/react' + +import { FormField, Language } from '~shared/types' +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 + + const hasDescription = + formFieldData.description && formFieldData.description !== '' + const titleTranslations = formFieldData.titleTranslations ?? [] + const descriptionTranslations = formFieldData.descriptionTranslations ?? [] + + const prevTitleTranslation = + titleTranslations.find( + (translation) => translation.language === unicodeLocale, + )?.translation ?? '' + + const prevDescriptionTranslation = + descriptionTranslations.find( + (translation) => translation.language === unicodeLocale, + )?.translation ?? '' + + const isTableField = formFieldData.fieldType === BasicField.Table + + return ( + <> + + + Question + + + + {hasDescription && ( + <> + + + + Description + + + + + )} + {(formFieldData.fieldType === BasicField.Radio || + formFieldData.fieldType === BasicField.Checkbox || + formFieldData.fieldType === BasicField.Dropdown) && ( + <> + + + + )} + {isTableField && ( + <> + + + + )} + + ) +} 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 + +