diff --git a/CHANGELOG.md b/CHANGELOG.md index 8611bfe5ea..cc71746b07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,31 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [v6.173.0](https://github.com/opengovsg/FormSG/compare/v6.173.0...v6.173.0) + +- fix: update mongodb binary version to 6 [`#8032`](https://github.com/opengovsg/FormSG/pull/8032) + +#### [v6.173.0](https://github.com/opengovsg/FormSG/compare/v6.172.0...v6.173.0) + +> 7 January 2025 + +- feat: multi lang feature [`#8022`](https://github.com/opengovsg/FormSG/pull/8022) +- fix(deps): bump fp-ts from 2.16.8 to 2.16.9 [`#8017`](https://github.com/opengovsg/FormSG/pull/8017) +- build: merge release v6.172.0 to develop [`#8016`](https://github.com/opengovsg/FormSG/pull/8016) +- build: release v6.172.0 [`#8015`](https://github.com/opengovsg/FormSG/pull/8015) +- chore: bump version to v6.173.0 [`1cb7772`](https://github.com/opengovsg/FormSG/commit/1cb7772374249830091d1fb3278a035957d23bcb) + #### [v6.172.0](https://github.com/opengovsg/FormSG/compare/v6.171.0...v6.172.0) +> 23 December 2024 + - fix(deps): bump next and react-email [`#8012`](https://github.com/opengovsg/FormSG/pull/8012) - fix(deps): bump type-fest from 4.26.1 to 4.30.2 in /shared [`#8009`](https://github.com/opengovsg/FormSG/pull/8009) - build(deps): bump next and react-email in /react-email-preview [`#8011`](https://github.com/opengovsg/FormSG/pull/8011) - fix(workflow): set approval toggle header size to h4 [`#8010`](https://github.com/opengovsg/FormSG/pull/8010) - build: merge release v6.171.0 to develop [`#8006`](https://github.com/opengovsg/FormSG/pull/8006) - build: release v6.171.0 [`#8003`](https://github.com/opengovsg/FormSG/pull/8003) +- chore: bump version to v6.172.0 [`84600fa`](https://github.com/opengovsg/FormSG/commit/84600faa9303791a3b438928adb5c357e32c5236) #### [v6.171.0](https://github.com/opengovsg/FormSG/compare/v6.170.0...v6.171.0) diff --git a/__tests__/setup/.test-env b/__tests__/setup/.test-env index 552f85f4b1..9f82d34d31 100644 --- a/__tests__/setup/.test-env +++ b/__tests__/setup/.test-env @@ -63,7 +63,7 @@ PAYMENT_PROOF_S3_BUCKET=local-payment-proof-bucket NODE_ENV=test FORMSG_SDK_MODE=test -MONGO_BINARY_VERSION=4.0.22 +MONGO_BINARY_VERSION=6.0.19 MOCK_WEBHOOK_CONFIG_FILE=webhook-server-config.csv MOCK_WEBHOOK_PORT=4000 diff --git a/__tests__/setup/database.js b/__tests__/setup/database.js index dca8bcd048..ae6ec57654 100644 --- a/__tests__/setup/database.js +++ b/__tests__/setup/database.js @@ -4,7 +4,7 @@ class MemoryDatabaseServer { constructor() { this.mongod = new MongoMemoryServer({ binary: { - version: process.env.MONGO_BINARY_VERSION || '4.0.22', + version: process.env.MONGO_BINARY_VERSION || '6.0.19', checkMD5: true, }, instance: {}, 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/package-lock.json b/frontend/package-lock.json index 6b6363c268..caa05e3155 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "form-frontend", - "version": "6.172.0", + "version": "6.173.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "form-frontend", - "version": "6.172.0", + "version": "6.173.0", "hasInstallScript": true, "dependencies": { "@chakra-ui/react": "^2.8.2", diff --git a/frontend/package.json b/frontend/package.json index d5dcf5dc7d..abfb41ba65 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "form-frontend", - "version": "6.172.0", + "version": "6.173.0", "homepage": ".", "type": "module", "private": true, 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 + +