diff --git a/assets/translations/en.json b/assets/translations/en.json index 03e2046c4..7120bf63b 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -411,6 +411,9 @@ "type_placeholder": "Please type text", "paragraph_placeholder": "Type in your answer here." }, + "character_counter":{ + "characters":"{{numberOfCharacters}}/{{limit}} characters" + }, "additional": { "server-error": "Server error occurred", "time-end": "Time is up!", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index dc471392f..a790408fc 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -399,6 +399,9 @@ "type_placeholder": "Veuiller saisir du texte", "paragraph_placeholder": "Tapez votre réponse ici." }, + "character_counter":{ + "characters":"{{numberOfCharacters}}/{{limit}} caractères" + }, "language_screen": { "app_language": " Langue de l'application", "change_app_language": "Changer la langue", diff --git a/src/features/pass-survey/model/AnswerValidator.ts b/src/features/pass-survey/model/AnswerValidator.ts index c4ff3939b..2ee2abbc4 100644 --- a/src/features/pass-survey/model/AnswerValidator.ts +++ b/src/features/pass-survey/model/AnswerValidator.ts @@ -6,6 +6,7 @@ import { PipelineItem, RadioResponse, TimeRangeResponse, + ParagraphTextResponse, } from '../lib'; type AnswerValidatorArgs = { @@ -106,6 +107,17 @@ function AnswerValidator(params?: AnswerValidatorArgs): IAnswerValidator { return true; } + case 'ParagraphText': { + const paragraphResponse = + currentAnswer?.answer as ParagraphTextResponse; + const limit = currentPipelineItem.payload.maxLength; + + if (limit < paragraphResponse.length) { + return false; + } + + return true; + } default: return true; } diff --git a/src/shared/ui/CharacterCounter.tsx b/src/shared/ui/CharacterCounter.tsx new file mode 100644 index 000000000..d13d371e3 --- /dev/null +++ b/src/shared/ui/CharacterCounter.tsx @@ -0,0 +1,64 @@ +import React, { FC } from 'react'; +import { StyleSheet } from 'react-native'; + +import { useTranslation } from 'react-i18next'; + +import { Logger } from '@app/shared/lib'; +import { Text } from '@shared/ui'; + +import { colors } from '../lib'; + +type Props = { + limit: number; + numberOfCharacters: number; + fontSize?: number; + focused?: boolean; +}; + +const CharacterCounter: FC = ({ + numberOfCharacters, + limit, + focused = false, +}) => { + const { t } = useTranslation(); + let colorStyle = focused ? styles.focusedColor : styles.unfocusedColor; + + if (limit < numberOfCharacters) colorStyle = styles.warnColor; + + if (limit <= 0) { + Logger.error('[CharacterCounter] Limit should be higher than 0'); + return null; + } + + if (numberOfCharacters < 0) { + Logger.error('[CharacterCounter] numberOfCharacters Cannot be less than 0'); + return null; + } + + return ( + + {t('character_counter:characters', { numberOfCharacters, limit })} + + ); +}; + +const styles = StyleSheet.create({ + characterCounterText: { + padding: 2, + margin: 2, + marginRight: 10, + fontWeight: '400', + fontSize: 14, + }, + focusedColor: { + color: colors.primary, + }, + unfocusedColor: { + color: colors.grey4, + }, + warnColor: { + color: colors.errorRed, + }, +}); + +export default CharacterCounter; diff --git a/src/shared/ui/LongTextInput.tsx b/src/shared/ui/LongTextInput.tsx index 08a2a212b..5a6417741 100644 --- a/src/shared/ui/LongTextInput.tsx +++ b/src/shared/ui/LongTextInput.tsx @@ -2,6 +2,7 @@ import { TextInput } from 'react-native'; import { GetProps, setupReactNative, styled } from '@tamagui/core'; import { focusableInputHOC } from '@tamagui/focusable'; +import { isTablet } from 'react-native-device-info'; setupReactNative({ TextInput, @@ -17,8 +18,8 @@ export const LongTextInput = styled( alignSelf: 'stretch', flex: 1, - minHeight: 56, - maxHeight: 350, + minHeight: 176, + maxHeight: isTablet() ? 350 : null, width: '100%', borderRadius: 12, borderWidth: 2, @@ -39,7 +40,7 @@ export const LongTextInput = styled( focusable: true, multiline: true, - scrollEnabled: true, + scrollEnabled: isTablet(), }, { isInput: true, diff --git a/src/shared/ui/index.tsx b/src/shared/ui/index.tsx index 90175d390..341adab6b 100644 --- a/src/shared/ui/index.tsx +++ b/src/shared/ui/index.tsx @@ -1,6 +1,7 @@ import CheckBox from '@react-native-community/checkbox'; import Center from './Center'; +import CharacterCounter from './CharacterCounter'; import Input from './Input'; import KeyboardAvoidingView from './KeyboardAvoidingView'; import Link from './Link'; @@ -48,6 +49,7 @@ export { KeyboardAvoidingView, Input, LongTextInput, + CharacterCounter, CheckBox, ScrollView, RowButton, diff --git a/src/shared/ui/survey/ParagraphText.tsx b/src/shared/ui/survey/ParagraphText.tsx index 2410329f5..9e21904f2 100644 --- a/src/shared/ui/survey/ParagraphText.tsx +++ b/src/shared/ui/survey/ParagraphText.tsx @@ -1,10 +1,10 @@ -import React, { FC } from 'react'; +import React, { FC, useState } from 'react'; import { StyleSheet, TextInputProps, View } from 'react-native'; import { useTranslation } from 'react-i18next'; import { colors } from '@shared/lib'; -import { LongTextInput } from '@shared/ui'; +import { LongTextInput, CharacterCounter } from '@shared/ui'; type Props = { onChange: (text: string) => void; @@ -15,6 +15,7 @@ type Props = { } & Omit; const ParagraphText: FC = ({ value, onChange, config, ...props }) => { + const [paragraphOnFocus, setParagraphOnFocus] = useState(false); const { maxLength = 50 } = config; const { t } = useTranslation(); @@ -29,13 +30,19 @@ const ParagraphText: FC = ({ value, onChange, config, ...props }) => { placeholder={t('text_entry:paragraph_placeholder')} placeholderTextColor={colors.mediumGrey} onChangeText={onChangeText} - maxLength={maxLength} value={value} autoCorrect={false} multiline={true} keyboardType={'default'} + onFocus={() => setParagraphOnFocus(true)} + onBlur={() => setParagraphOnFocus(false)} {...props} /> + ); }; @@ -45,5 +52,6 @@ export default ParagraphText; const styles = StyleSheet.create({ container: { flex: 1, + alignItems: 'flex-end', }, }); diff --git a/src/shared/ui/survey/tests/TextParagraph.test.tsx b/src/shared/ui/survey/tests/TextParagraph.test.tsx index 639caee45..8e0dd661e 100644 --- a/src/shared/ui/survey/tests/TextParagraph.test.tsx +++ b/src/shared/ui/survey/tests/TextParagraph.test.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import renderer from 'react-test-renderer'; +import renderer, { act } from 'react-test-renderer'; import TamaguiProvider from '@app/app/ui/AppProvider/TamaguiProvider'; -import { LongTextInput } from '@shared/ui'; +import { LongTextInput, CharacterCounter } from '@shared/ui'; import ParagraphText from '../ParagraphText'; @@ -13,12 +13,12 @@ jest.mock('react-i18next', () => ({ })), })); -describe('Test Paragraph Text', () => { +describe('ParagraphText Component', () => { afterEach(() => { jest.clearAllMocks(); }); - it('Should renders correctly with expected props', () => { + it('Should render correctly with expected props', () => { const mockValue = '1234'; const tree = renderer @@ -35,23 +35,30 @@ describe('Test Paragraph Text', () => { ) .toJSON(); - if (!tree || Array.isArray(tree)) { - throw new Error('Tree is not rendered correctly or is an array'); + if (!tree || Array.isArray(tree) || !tree.children) { + throw new Error( + 'Tree is not rendered correctly or is an array without children', + ); } - const view = tree as any; - const longTextInput = view.children[0]; + const [longTextInput] = tree.children; + + if ( + !longTextInput || + typeof longTextInput !== 'object' || + !('props' in longTextInput) + ) { + throw new Error('LongTextInput is not rendered correctly'); + } expect(longTextInput.props.placeholder).toBe( 'text_entry:paragraph_placeholder', ); - expect(longTextInput.props.value).toBe('1234'); - expect(longTextInput.props.maxLength).toBe(300); + expect(longTextInput.props.value).toBe(mockValue); }); it('Should call onChange when text is modified', () => { const mockOnChange = jest.fn(); - const tree = renderer.create( { const instance = tree.root; const longTextInput = instance.findByType(LongTextInput); - longTextInput.props.onChangeText('new text'); + act(() => { + longTextInput.props.onChangeText('new text'); + }); + expect(mockOnChange).toHaveBeenCalledWith('new text'); }); - it('Should handle maxLength configurations correctly', () => { - const tree = renderer - .create( - - - , - ) - .toJSON(); + it('Should update focus state when input is focused or blurred', () => { + const tree = renderer.create( + + + , + ); - if (!tree || Array.isArray(tree)) { - throw new Error('Tree is not rendered correctly or is an array'); - } + const instance = tree.root; + const longTextInput = instance.findByType(LongTextInput); + const characterCounter = instance.findByType(CharacterCounter); - const view = tree as any; - const longTextInput = view.children[0]; + expect(characterCounter.props.focused).toBe(false); + + act(() => { + longTextInput.props.onFocus(); + }); + + expect(characterCounter.props.focused).toBe(true); + + act(() => { + longTextInput.props.onBlur(); + }); + + expect(characterCounter.props.focused).toBe(false); + }); - expect(longTextInput.props.maxLength).toBe(150); + it('Should pass correct number of characters and maxLength to CharacterCounter', () => { + const mockValue = '123456'; + const maxLength = 100; + + const tree = renderer.create( + + + , + ); + + const instance = tree.root; + const characterCounter = instance.findByType(CharacterCounter); + expect(characterCounter.props.numberOfCharacters).toBe(mockValue.length); + expect(characterCounter.props.limit).toBe(maxLength); }); }); diff --git a/src/shared/ui/tests/CharacterCounter.test.tsx b/src/shared/ui/tests/CharacterCounter.test.tsx new file mode 100644 index 000000000..83a2b954a --- /dev/null +++ b/src/shared/ui/tests/CharacterCounter.test.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { Text } from 'react-native'; + +import { useTranslation } from 'react-i18next'; +import renderer from 'react-test-renderer'; + +import TamaguiProvider from '@app/app/ui/AppProvider/TamaguiProvider'; +import { CharacterCounter } from '@shared/ui'; + +import { colors } from '../../lib'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: jest.fn((key, options) => { + if (key === 'character_counter:characters') { + return `${options.numberOfCharacters}/${options.limit} characters`; + } + return key; + }), + }), +})); + +describe('CharacterCounter Component', () => { + it('Should apply the primary color when focused', () => { + const tree = renderer.create( + + + , + ); + + const textElement = tree.root.findByType(Text); + expect(textElement.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ color: colors.primary }), + ]), + ); + }); + + it('Should apply the grey color when not focused', () => { + const tree = renderer.create( + + + , + ); + + const textElement = tree.root.findByType(Text); + expect(textElement.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ color: colors.grey4 }), + ]), + ); + }); + + it('Should display the correct character count', () => { + const tree = renderer.create( + + + , + ); + + const textElement = tree.root.findByType(Text); + + const characterCountText = Array.isArray(textElement.props.children) + ? textElement.props.children.join('') + : textElement.props.children; + + expect(characterCountText).toBe('150/200 characters'); + }); + + it('Should handle extreme values correctly', () => { + const tree = renderer.create( + + + , + ); + + const textElement = tree.root.findByType(Text); + + const characterCountText = Array.isArray(textElement.props.children) + ? textElement.props.children.join('') + : textElement.props.children; + + expect(characterCountText).toBe('9999/10000 characters'); + }); + + it('Should handle missing translation key gracefully', () => { + jest + .spyOn(useTranslation(), 't') + .mockImplementation(() => 'Translation missing'); + + const tree = renderer.create( + + + , + ); + + const textElement = tree.root.findByType(Text); + + const characterCountText = Array.isArray(textElement.props.children) + ? textElement.props.children.join('') + : textElement.props.children; + + expect(characterCountText).toBe('50/100 characters'); + }); + + it('Should apply custom styles from the stylesheet', () => { + const tree = renderer.create( + + + , + ); + + const textElement = tree.root.findByType(Text); + expect(textElement.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ padding: 2, margin: 2, marginRight: 10 }), + ]), + ); + }); +});