Skip to content

Commit

Permalink
feat: add translations to public form (#7976)
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 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]>
  • Loading branch information
siddarth2824 and LoneRifle authored Dec 29, 2024
1 parent bd4ac8e commit 4cf742d
Show file tree
Hide file tree
Showing 88 changed files with 1,603 additions and 270 deletions.
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
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const SettingsMultiLangPage = (): JSX.Element => {
const isTranslationInput = !_.isNull(translationInput)
const isEndPageTranslationInput = translationInput === 'endPage'
const isStartPageTransltionInput = translationInput === 'startPage'
const isFormLogicTranslationInput = translationInput === 'formLogic'

// Request user to select a language
if (!unicodeLocale) {
Expand All @@ -23,16 +24,21 @@ export const SettingsMultiLangPage = (): JSX.Element => {
if (!isTranslationInput) {
return <TranslationListSection language={unicodeLocale} />
}

const formFieldToBeTranslated =
isEndPageTranslationInput ||
isStartPageTransltionInput ||
isFormLogicTranslationInput
? -1
: _.toNumber(translationInput)

return (
<TranslationSection
language={unicodeLocale}
formFieldNumToBeTranslated={
isEndPageTranslationInput || isStartPageTransltionInput
? -1
: _.toNumber(translationInput)
}
formFieldNumToBeTranslated={formFieldToBeTranslated}
isEndPageTranslations={isEndPageTranslationInput}
isStartPageTranslations={isStartPageTransltionInput}
isFormLogicTranslations={isFormLogicTranslationInput}
/>
)
}
Loading

0 comments on commit 4cf742d

Please sign in to comment.