Skip to content

Commit

Permalink
feat: multi lang feature (#8022)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

---------

Co-authored-by: LoneRifle <[email protected]>
  • Loading branch information
siddarth2824 and LoneRifle authored Jan 6, 2025
1 parent 613263d commit 00de8e1
Show file tree
Hide file tree
Showing 114 changed files with 3,929 additions and 194 deletions.
6 changes: 6 additions & 0 deletions __tests__/unit/backend/helpers/generate-form-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export const generateDefaultField = (
fieldType,
required: true,
disabled: false,
titleTranslations: [],
descriptionTranslations: [],
}
switch (fieldType) {
case BasicField.Table:
Expand All @@ -102,6 +104,7 @@ export const generateDefaultField = (
return {
...defaultParams,
fieldOptions: ['Option 1', 'Option 2'],
fieldOptionsTranslations: [],
getQuestion: () => defaultParams.title,
ValidationOptions: {
customMin: null,
Expand Down Expand Up @@ -141,6 +144,7 @@ export const generateDefaultField = (
return {
...defaultParams,
fieldOptions: ['Option 1', 'Option 2'],
fieldOptionsTranslations: [],
getQuestion: () => defaultParams.title,
...customParams,
} as IDropdownFieldSchema
Expand Down Expand Up @@ -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
Expand All @@ -410,6 +415,7 @@ export const generateTableDropdownColumn = (
required: true,
_id: new ObjectId().toHexString(),
fieldOptions: ['a', 'b', 'c'],
fieldOptionsTranslations: [],
...customParams,
}
},
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/assets/icons/LanguageTranslation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const LanguageTranslation = (
props: React.SVGProps<SVGSVGElement>,
): JSX.Element => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
fill="currentColor"
className="bi bi-translate"
viewBox="0 0 16 16"
{...props}
>
<path d="M4.545 6.714 4.11 8H3l1.862-5h1.284L8 8H6.833l-.435-1.286zm1.634-.736L5.5 3.956h-.049l-.679 2.022z" />
<path d="M0 2a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v3h3a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-3H2a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zm7.138 9.995q.289.451.63.846c-.748.575-1.673 1.001-2.768 1.292.178.217.451.635.555.867 1.125-.359 2.08-.844 2.886-1.494.777.665 1.739 1.165 2.93 1.472.133-.254.414-.673.629-.89-1.125-.253-2.057-.694-2.82-1.284.681-.747 1.222-1.651 1.621-2.757H14V8h-3v1.047h.765c-.318.844-.74 1.546-1.272 2.13a6 6 0 0 1-.415-.492 2 2 0 0 1-.94.31" />
</svg>
)
2 changes: 1 addition & 1 deletion frontend/src/components/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ const OthersCheckbox = forwardRef<CheckboxProps, 'input'>((props, ref) => {
{...props}
onChange={handleCheckboxChange}
>
{t('features.adminForm.sidebar.fields.radio.others')}
{t('features.publicForm.components.fields.option.others')}
</Checkbox>
)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ export const SingleSelectProvider = ({
onChange,
name,
filter = defaultFilter,
nothingFoundLabel = 'No matching results',
placeholder: placeholderProp,
clearButtonLabel = 'Clear selection',
isClearable = true,
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 7 additions & 7 deletions frontend/src/components/Field/Attachment/Attachment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,14 @@ export const Attachment = forwardRef<AttachmentProps, 'div'>(
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
}
Expand All @@ -178,15 +178,15 @@ export const Attachment = forwardRef<AttachmentProps, 'div'>(
const stringOfInvalidExtensions = invalidFilesInZip.join(', ')
return onError?.(
t(
'features.adminForm.sidebar.fields.imageAttachment.error.zipFileInvalidType',
'features.publicForm.components.fields.attachment.error.zipFileInvalidType',
{ stringOfInvalidExtensions },
),
)
}
} catch {
return onError?.(
t(
'features.adminForm.sidebar.fields.imageAttachment.error.zipParsing',
'features.publicForm.components.fields.attachment.error.zipParsing',
),
)
}
Expand Down Expand Up @@ -224,7 +224,7 @@ export const Attachment = forwardRef<AttachmentProps, 'div'>(
return {
code: 'file-too-large',
message: t(
'features.adminForm.sidebar.fields.imageAttachment.error.fileTooLarge',
'features.publicForm.components.fields.attachment.error.fileTooLarge',
{ readableMaxSize },
),
}
Expand All @@ -233,7 +233,7 @@ export const Attachment = forwardRef<AttachmentProps, 'div'>(
return {
code: 'file-empty',
message: t(
'features.adminForm.sidebar.fields.imageAttachment.error.zipParsing',
'features.publicForm.components.fields.attachment.error.zipParsing',
),
}
}
Expand Down Expand Up @@ -336,7 +336,7 @@ export const Attachment = forwardRef<AttachmentProps, 'div'>(
aria-hidden
>
{t(
'features.adminForm.sidebar.fields.imageAttachment.maxFileSize',
'features.publicForm.components.fields.attachment.maxFileSize',
{
readableMaxSize,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ export const AttachmentDropzone = ({
<Text aria-hidden>
<Link isDisabled={inputProps.disabled}>
{t(
'features.adminForm.sidebar.fields.imageAttachment.fileUploaderLink',
'features.publicForm.components.fields.attachment.fileUploaderLink',
)}
</Link>
{t('features.adminForm.sidebar.fields.imageAttachment.dragAndDrop')}
{t('features.publicForm.components.fields.attachment.dragAndDrop')}
</Text>
)}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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={<BiTrash />}
onClick={handleRemoveFile}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/Field/YesNo/YesNo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export const YesNo = forwardRef<YesNoProps, 'input'>(
{...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}
Expand All @@ -100,7 +100,7 @@ export const YesNo = forwardRef<YesNoProps, 'input'>(
{...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}
/>
</HStack>
Expand Down
27 changes: 21 additions & 6 deletions frontend/src/components/FormEndPage/EndPageBlock.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -27,6 +29,7 @@ export const EndPageBlock = ({
focusOnMount,
isButtonHidden,
}: EndPageBlockProps): JSX.Element => {
const { i18n } = useTranslation()
const focusRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (focusOnMount) {
Expand All @@ -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],
Expand All @@ -62,13 +79,11 @@ export const EndPageBlock = ({
{submittedAriaText}
</VisuallyHidden>
<Text as="h2" textStyle="h2" textColor="secondary.500">
{endPage.title}
{title}
</Text>
{endPage.paragraph ? (
{paragraph ? (
<Box mt="0.75rem">
<MarkdownText components={mdComponents}>
{endPage.paragraph}
</MarkdownText>
<MarkdownText components={mdComponents}>{paragraph}</MarkdownText>
</Box>
) : null}
</Box>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Radio/Radio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ const OthersRadio = forwardRef<RadioProps, 'input'>((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')}
</Radio>
)
})
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
2 changes: 0 additions & 2 deletions frontend/src/constants/validation.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/features/admin-form/create/CreatePage.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export const EditCheckbox = ({ field }: EditCheckboxProps): JSX.Element => {
<FormControl isReadOnly={isLoading}>
<Toggle
{...register('othersRadioButton')}
label={t('features.adminForm.sidebar.fields.radio.others')}
label={t('features.publicForm.components.fields.option.others')}
/>
</FormControl>
<FormControl
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export const EditRadio = ({ field }: EditRadioProps): JSX.Element => {
<FormControl isReadOnly={isLoading}>
<Toggle
{...register('othersRadioButton')}
label={t('features.adminForm.sidebar.fields.radio.others')}
label={t('features.publicForm.components.fields.option.others')}
/>
</FormControl>
<FormControl
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,11 +220,19 @@ export const getMyInfoFieldCreationMeta = (
): MyInfoField => {
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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions frontend/src/features/admin-form/create/logic/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export enum AdminEditLogicState {
export type EditLogicInputs = FormLogic & {
preventSubmitMessage?: PreventSubmitLogic['preventSubmitMessage']
show?: ShowFieldLogic['show']
preventSubmitMessageTranslations?: PreventSubmitLogic['preventSubmitMessageTranslations']
}
2 changes: 2 additions & 0 deletions frontend/src/features/admin-form/preview/PreviewFormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -31,6 +32,7 @@ export const PreviewFormPage = (): JSX.Element => {
<FormSectionsProvider>
<PublicFormLogo />
<FormStartPage />
<LanguageControl />
<PublicFormWrapper>
<FormInstructions />
<FormFields />
Expand Down
Loading

0 comments on commit 00de8e1

Please sign in to comment.