diff --git a/__snapshots__/features/search/pages/modals/PriceModal/PriceModal.native.test.tsx.native-snap b/__snapshots__/features/search/pages/modals/PriceModal/PriceModal.native.test.tsx.native-snap index 2c3b2104c20..158dd34ead5 100644 --- a/__snapshots__/features/search/pages/modals/PriceModal/PriceModal.native.test.tsx.native-snap +++ b/__snapshots__/features/search/pages/modals/PriceModal/PriceModal.native.test.tsx.native-snap @@ -377,83 +377,80 @@ exports[` should render modal correctly after animation and with en } > - - - undefined-SVG-Mock - - - - - - Il te reste 70 € sur ton pass Culture. + + undefined-SVG-Mock + > + + Il te reste 70 € sur ton pass Culture. + + + should render modal correctly after animation and with en - - should render modal correctly after animation and with en - - ', () => { + it('should not show error when form input is valid', () => { + renderPriceInputController({}) + + expect(screen.queryByText('error')).not.toBeOnTheScreen() + }) + + it('should show error when form input is invalid', () => { + renderPriceInputController({ + error: { type: 'custom', message: 'error' }, + }) + + expect(screen.getByText('error')).toBeOnTheScreen() + }) + + it('should display custom error message when error is set', async () => { + renderPriceInputController({ + error: { type: 'custom', message: 'Prix invalide' }, + }) + + const input = screen.getByPlaceholderText('Prix') + await user.type(input, 'abc') + + expect(screen.getByText('Prix invalide')).toBeOnTheScreen() + }) +}) + +const renderPriceInputController = ({ + error, + isDisabled, +}: { + error?: ErrorOption + isDisabled?: boolean +}) => { + const PriceForm = () => { + const { control, setError } = useForm({ + defaultValues: { price: '' }, + }) + + error && setError('price', error) + return ( + + ) + } + render() +} diff --git a/src/features/search/components/PriceInputController/PriceInputController.tsx b/src/features/search/components/PriceInputController/PriceInputController.tsx new file mode 100644 index 00000000000..b47e0bac266 --- /dev/null +++ b/src/features/search/components/PriceInputController/PriceInputController.tsx @@ -0,0 +1,64 @@ +import React, { PropsWithChildren, ReactElement } from 'react' +import { Control, Controller, FieldPath, FieldValues } from 'react-hook-form' + +import { InputError } from 'ui/components/inputs/InputError' +import { TextInput } from 'ui/components/inputs/TextInput' +import { getSpacing } from 'ui/theme/spacing' + +interface Props + extends Omit, 'value' | 'onChangeText'> { + name: TName + control: Control + label: string + placeholder?: string + rightLabel?: string + isDisabled?: boolean + accessibilityId: string +} + +export const PriceInputController = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + name, + control, + label, + placeholder, + rightLabel, + isDisabled, + accessibilityId, + ...textInputProps +}: PropsWithChildren>): ReactElement => { + return ( + ( + + 0} + textContentType="none" + accessibilityDescribedBy={accessibilityId} + keyboardType="numeric" + autoComplete="off" + autoCapitalize="none" + {...textInputProps} + /> + + + )} + /> + ) +} diff --git a/src/features/search/components/sections/Price/Price.native.test.tsx b/src/features/search/components/sections/Price/Price.native.test.tsx index ac976bf5097..ad37e176633 100644 --- a/src/features/search/components/sections/Price/Price.native.test.tsx +++ b/src/features/search/components/sections/Price/Price.native.test.tsx @@ -2,8 +2,10 @@ import React from 'react' import { Price } from 'features/search/components/sections/Price/Price' import { initialSearchState } from 'features/search/context/reducer' +import { DEFAULT_PACIFIC_FRANC_TO_EURO_RATE } from 'libs/firebase/firestore/exchangeRates/useGetPacificFrancToEuroRate' import { setFeatureFlags } from 'libs/firebase/firestore/featureFlags/__tests__/setFeatureFlags' -import { fireEvent, render, screen } from 'tests/utils' +import { Currency } from 'shared/currency/useGetCurrencyToDisplay' +import { render, screen, userEvent } from 'tests/utils' let mockSearchState = initialSearchState @@ -26,6 +28,14 @@ jest.mock('react-native/Libraries/Animated/createAnimatedComponent', () => { } }) +const user = userEvent.setup() +jest.useFakeTimers() + +const props = { + currency: Currency.EURO, + euroToPacificFrancRate: DEFAULT_PACIFIC_FRANC_TO_EURO_RATE, +} + describe('Price component', () => { beforeEach(() => { setFeatureFlags() @@ -33,7 +43,7 @@ describe('Price component', () => { it('should display the search price description when minimum price selected', async () => { mockSearchState = { ...initialSearchState, minPrice: '5' } - render() + render() await screen.findByText('Prix') @@ -42,7 +52,7 @@ describe('Price component', () => { it('should display the search price description when maximum price selected', async () => { mockSearchState = { ...initialSearchState, maxPrice: '10' } - render() + render() await screen.findByText('Prix') @@ -51,7 +61,7 @@ describe('Price component', () => { it('should display the search price description when minimum and maximum prices selected', async () => { mockSearchState = { ...initialSearchState, minPrice: '5', maxPrice: '10' } - render() + render() await screen.findByText('Prix') @@ -60,7 +70,7 @@ describe('Price component', () => { it('should display the search price description with "Gratuit" when minimum and maximum prices selected and are 0', async () => { mockSearchState = { ...initialSearchState, minPrice: '0', maxPrice: '0' } - render() + render() await screen.findByText('Prix') @@ -68,12 +78,12 @@ describe('Price component', () => { }) it('should open the categories filter modal when clicking on the category button', async () => { - render(, { + render(, { theme: { isDesktopViewport: false, isMobileViewport: true }, }) - const searchPriceButton = await screen.findByTestId('FilterRow') - fireEvent.press(searchPriceButton) + const searchPriceButton = await screen.findByTestId('FilterRow') + await user.press(searchPriceButton) const fullscreenModalScrollView = screen.getByTestId('fullscreenModalScrollView') diff --git a/src/features/search/components/sections/Price/Price.tsx b/src/features/search/components/sections/Price/Price.tsx index add14999afa..56596b198ad 100644 --- a/src/features/search/components/sections/Price/Price.tsx +++ b/src/features/search/components/sections/Price/Price.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useMemo } from 'react' import { FilterRow } from 'features/search/components/FilterRow/FilterRow' import { useSearch } from 'features/search/context/SearchWrapper' @@ -6,20 +6,19 @@ import { FilterBehaviour } from 'features/search/enums' import { getPriceAsNumber } from 'features/search/helpers/getPriceAsNumber/getPriceAsNumber' import { getPriceDescription } from 'features/search/helpers/getPriceDescription/getPriceDescription' import { PriceModal } from 'features/search/pages/modals/PriceModal/PriceModal' -import { useGetPacificFrancToEuroRate } from 'libs/firebase/firestore/exchangeRates/useGetPacificFrancToEuroRate' -import { useGetCurrencyToDisplay } from 'shared/currency/useGetCurrencyToDisplay' +import { Currency } from 'shared/currency/useGetCurrencyToDisplay' import { useModal } from 'ui/components/modals/useModal' import { Code } from 'ui/svg/icons/Code' type Props = { onClose?: VoidFunction + currency: Currency + euroToPacificFrancRate: number } -export const Price = ({ onClose }: Props) => { - const currency = useGetCurrencyToDisplay() - const euroToPacificFrancRate = useGetPacificFrancToEuroRate() - +export const Price = ({ onClose, currency, euroToPacificFrancRate }: Props) => { const { searchState } = useSearch() + const { visible: searchPriceModalVisible, showModal: showSearchPriceModal, @@ -33,14 +32,14 @@ export const Price = ({ onClose }: Props) => { showSearchPriceModal() }, [showSearchPriceModal]) + const description = useMemo( + () => getPriceDescription(currency, euroToPacificFrancRate, minPrice, maxPrice), + [currency, euroToPacificFrancRate, maxPrice, minPrice] + ) + return ( - + { - describe('should match', () => { - it('a valid integer price', async () => { - const result = await makePriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate('123') - - expect(result).toEqual('123') - }) - - it('a valid price with a dot', async () => { - const result = await makePriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate('123.45') - - expect(result).toEqual('123.45') - }) - - it('a valid price with a comma', async () => { - const result = await makePriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate('123,45') - - expect(result).toEqual('123,45') - }) - - it('a valid price with only one decimal', async () => { - const result = await makePriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate('123,4') - - expect(result).toEqual('123,4') - }) - - it('a valid price between spaces', async () => { - const result = await makePriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate(' 123,4 ') - - expect(result).toEqual('123,4') - }) - - it('a valid price with a dot but no decimal', async () => { - const result = await makePriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate('123.') - - expect(result).toEqual('123.') - }) - - it('when input less than the initial credit', async () => { - const result = await makePriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate('200') - - expect(result).toEqual('200') - }) - - it('when input equal to the initial credit', async () => { - const result = await makePriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate(convertCentsToEuros(MAX_PRICE_IN_CENTS).toString()) - - expect(result).toEqual(convertCentsToEuros(MAX_PRICE_IN_CENTS).toString()) - }) - - it('when input is undefined', async () => { - const result = await makePriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate(undefined) - - expect(result).toEqual(undefined) - }) - }) - - describe('should invalidate an input', () => { - const formatErrorMessage = `Format du prix incorrect. Exemple de format attendu\u00a0: 10,00` - const maxPriceErrorMessage = `Le prix indiqué ne doit pas dépasser ${convertCentsToEuros(MAX_PRICE_IN_CENTS)}\u00a0€` - - it('that is not a number', async () => { - const result = makePriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate('azerty') - - await expect(result).rejects.toEqual(new ValidationError(formatErrorMessage)) - }) - - it('that is a number with more than 2 decimals', async () => { - const result = makePriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate('123.456') - - await expect(result).rejects.toEqual(new ValidationError(formatErrorMessage)) - }) - - it('that is a number with more than 2 comma or dot', async () => { - const result = makePriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate('1,23.45') - - await expect(result).rejects.toEqual(new ValidationError(formatErrorMessage)) - }) - - it('that is a number with negative number', async () => { - const result = makePriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate('-123.45') - - await expect(result).rejects.toEqual(new ValidationError(formatErrorMessage)) - }) - - it('with euro symbol', async () => { - const result = makePriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate('123.45€') - - await expect(result).rejects.toEqual(new ValidationError(formatErrorMessage)) - }) - - it('when input higher than the initial credit', async () => { - const result = makePriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate('400') - - await expect(result).rejects.toEqual(new ValidationError(maxPriceErrorMessage)) - }) - }) -}) diff --git a/src/features/search/helpers/schema/makePriceSchema/makePriceSchema.ts b/src/features/search/helpers/schema/makePriceSchema/makePriceSchema.ts deleted file mode 100644 index 86346a7ae4a..00000000000 --- a/src/features/search/helpers/schema/makePriceSchema/makePriceSchema.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { string } from 'yup' - -import { formatCurrencyFromCents } from 'libs/parsers/formatCurrencyFromCents' -import { convertEuroToCents } from 'libs/parsers/pricesConversion' -import { Currency } from 'shared/currency/useGetCurrencyToDisplay' - -// integers separated by a dot or comma with 2 decimals max -const PRICE_REGEX = /^\d+(?:[,.]\d{0,2})?$/ -const formatPriceError = `Format du prix incorrect. Exemple de format attendu\u00a0: 10,00` - -const maxPriceError = ( - initialCreditInEuro: string, - currency: Currency, - euroToPacificFrancRate: number -) => { - const initialCreditInCents = convertEuroToCents(Number(initialCreditInEuro)) - const maxPrice = formatCurrencyFromCents(initialCreditInCents, currency, euroToPacificFrancRate) - return `Le prix indiqué ne doit pas dépasser ${maxPrice}` -} - -export const makePriceSchema = ( - initialCreditInEuro: string, - currency: Currency, - euroToPacificFrancRate: number -) => - string() - .trim() - .test('validPrice', formatPriceError, (value) => { - if (!value) return true - return PRICE_REGEX.test(value.trim()) - }) - .test( - 'validMaxPrice', - maxPriceError(initialCreditInEuro, currency, euroToPacificFrancRate), - (value) => { - if (!value) return true - return Number(value.trim().replaceAll(',', '.')) <= Number(initialCreditInEuro) - } - ) diff --git a/src/features/search/helpers/schema/makeSearchPriceSchema/makeSearchPriceSchema.native.test.ts b/src/features/search/helpers/schema/makeSearchPriceSchema/makeSearchPriceSchema.native.test.ts deleted file mode 100644 index 31300bffaad..00000000000 --- a/src/features/search/helpers/schema/makeSearchPriceSchema/makeSearchPriceSchema.native.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { ValidationError } from 'yup' - -import { MAX_PRICE_IN_CENTS } from 'features/search/helpers/reducer.helpers' -import { - minPriceError, - makeSearchPriceSchema, -} from 'features/search/helpers/schema/makeSearchPriceSchema/makeSearchPriceSchema' -import { DEFAULT_PACIFIC_FRANC_TO_EURO_RATE } from 'libs/firebase/firestore/exchangeRates/useGetPacificFrancToEuroRate' -import { convertCentsToEuros } from 'libs/parsers/pricesConversion' -import { Currency } from 'shared/currency/useGetCurrencyToDisplay' - -describe('search price schema', () => { - const initialValues = { - isLimitCreditSearch: false, - isOnlyFreeOffersSearch: false, - } - - describe('should match minimum price', () => { - it('when input less than maximum price input', async () => { - const values = { ...initialValues, maxPrice: '20', minPrice: '10' } - const result = await makeSearchPriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate(values) - - expect(result).toEqual(values) - }) - - it('when input equal than maximum price input', async () => { - const values = { ...initialValues, maxPrice: '20', minPrice: '20' } - const result = await makeSearchPriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate(values) - - expect(result).toEqual(values) - }) - - it('when input less than decimal maximum price input', async () => { - const values = { ...initialValues, maxPrice: '20,15', minPrice: '10' } - const result = await makeSearchPriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate(values) - - expect(result).toEqual(values) - }) - - it('when input equal than decimal maximum price input', async () => { - const values = { ...initialValues, maxPrice: '20,15', minPrice: '20,15' } - const result = await makeSearchPriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate(values) - - expect(result).toEqual(values) - }) - }) - - it('should match decimal minimum price when input less than maximum price input', async () => { - const values = { ...initialValues, maxPrice: '20', minPrice: '10,50' } - const result = await makeSearchPriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate(values) - - expect(result).toEqual(values) - }) - - it('should invalidate minimum price when input higher than maximum price', async () => { - const values = { ...initialValues, maxPrice: '20', minPrice: '21' } - const result = makeSearchPriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate(values) - - await expect(result).rejects.toEqual(new ValidationError(minPriceError)) - }) - - it('should invalidate minimum price when input higher than decimal maximum price', async () => { - const values = { ...initialValues, maxPrice: '20,15', minPrice: '21' } - const result = makeSearchPriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate(values) - - await expect(result).rejects.toEqual(new ValidationError(minPriceError)) - }) - - it('should invalidate decimal minimum price when input higher than maximum price', async () => { - const values = { ...initialValues, maxPrice: '20', minPrice: '21,15' } - const result = makeSearchPriceSchema( - convertCentsToEuros(MAX_PRICE_IN_CENTS).toString(), - Currency.EURO, - DEFAULT_PACIFIC_FRANC_TO_EURO_RATE - ).validate(values) - - await expect(result).rejects.toEqual(new ValidationError(minPriceError)) - }) -}) diff --git a/src/features/search/helpers/schema/makeSearchPriceSchema/makeSearchPriceSchema.ts b/src/features/search/helpers/schema/makeSearchPriceSchema/makeSearchPriceSchema.ts deleted file mode 100644 index dc15a9fdeb2..00000000000 --- a/src/features/search/helpers/schema/makeSearchPriceSchema/makeSearchPriceSchema.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { boolean, object } from 'yup' - -import { makePriceSchema } from 'features/search/helpers/schema/makePriceSchema/makePriceSchema' -import { Currency } from 'shared/currency/useGetCurrencyToDisplay' - -export const minPriceError = `Le montant minimum ne peut pas dépasser le montant maximum` - -export const makeSearchPriceSchema = ( - initialCredit: string, - currency: Currency, - euroToPacificFrancRate: number -) => - object().shape({ - minPrice: makePriceSchema(initialCredit.toString(), currency, euroToPacificFrancRate).when( - ['maxPrice'], - { - is: (maxPrice: string) => maxPrice.length > 0, - then: (schema) => - schema.test('validMinPrice', minPriceError, (value, schema) => { - if (!value) return true - return ( - Number(value.trim().replaceAll(',', '.')) <= - Number(schema.parent.maxPrice.trim().replaceAll(',', '.')) - ) - }), - } - ), - maxPrice: makePriceSchema(String(initialCredit), currency, euroToPacificFrancRate), - isLimitCreditSearch: boolean(), - isOnlyFreeOffersSearch: boolean(), - }) diff --git a/src/features/search/helpers/schema/priceSchema/priceSchema.native.test.ts b/src/features/search/helpers/schema/priceSchema/priceSchema.native.test.ts new file mode 100644 index 00000000000..8ec5ecb4320 --- /dev/null +++ b/src/features/search/helpers/schema/priceSchema/priceSchema.native.test.ts @@ -0,0 +1,184 @@ +import { ValidationError } from 'yup' + +import { Currency } from 'shared/currency/useGetCurrencyToDisplay' + +import { priceSchema } from './priceSchema' + +describe('priceSchema', () => { + const initialCredit = 100 + const currency = Currency.EURO + + it('should validate correctly when minPrice and maxPrice are within range', async () => { + const validData = { + minPrice: '10,00', + maxPrice: '50,00', + isLimitCreditSearch: true, + isOnlyFreeOffersSearch: false, + } + + await expect(priceSchema({ initialCredit, currency }).isValid(validData)).resolves.toBe(true) + }) + + it('should invalidate when maxPrice exceeds initialCredit', async () => { + const invalidData = { + minPrice: '10,00', + maxPrice: '150,00', + isLimitCreditSearch: false, + isOnlyFreeOffersSearch: false, + } + + await expect(priceSchema({ initialCredit, currency }).isValid(invalidData)).resolves.toBe(false) + }) + + it('should invalidate when minPrice is greater than maxPrice', async () => { + const invalidData = { + minPrice: '60,00', + maxPrice: '50,00', + isLimitCreditSearch: true, + isOnlyFreeOffersSearch: false, + } + + await expect(priceSchema({ initialCredit, currency }).isValid(invalidData)).resolves.toBe(false) + }) + + it('should validate when maxPrice is empty and minPrice is within range', async () => { + const validData = { + minPrice: '10,00', + maxPrice: '', + isLimitCreditSearch: false, + isOnlyFreeOffersSearch: false, + } + + await expect(priceSchema({ initialCredit, currency }).isValid(validData)).resolves.toBe(true) + }) + + it('should invalidate when minPrice has incorrect format', async () => { + const invalidData = { + minPrice: '10,000', + maxPrice: '50,00', + isLimitCreditSearch: true, + isOnlyFreeOffersSearch: false, + } + + await expect(priceSchema({ initialCredit, currency }).isValid(invalidData)).resolves.toBe(false) + }) + + it('should invalidate when maxPrice has incorrect format', async () => { + const invalidData = { + minPrice: '10,00', + maxPrice: '50.000', + isLimitCreditSearch: false, + isOnlyFreeOffersSearch: false, + } + + await expect(priceSchema({ initialCredit, currency }).isValid(invalidData)).resolves.toBe(false) + }) + + describe('should fail', () => { + it('when maxPrice exceeds initialCredit', async () => { + const invalidData = { + minPrice: '10,00', + maxPrice: '150,00', + isLimitCreditSearch: false, + isOnlyFreeOffersSearch: false, + } + + await expect(priceSchema({ initialCredit, currency }).validate(invalidData)).rejects.toEqual( + new ValidationError('Le prix indiqué ne doit pas dépasser 100\u00a0€') + ) + }) + + it('when maxPrice exceeds initialCredit with other currency', async () => { + const invalidData = { + minPrice: '10,00', + maxPrice: '150,00', + isLimitCreditSearch: false, + isOnlyFreeOffersSearch: false, + } + + await expect( + priceSchema({ initialCredit, currency: Currency.PACIFIC_FRANC_SHORT }).validate(invalidData) + ).rejects.toEqual(new ValidationError('Le prix indiqué ne doit pas dépasser 100\u00a0F')) + }) + + it('when minPrice is greater than maxPrice', async () => { + const invalidData = { + minPrice: '50,00', + maxPrice: '40,00', + isLimitCreditSearch: true, + isOnlyFreeOffersSearch: false, + } + + await expect(priceSchema({ initialCredit, currency }).validate(invalidData)).rejects.toEqual( + new ValidationError('Le montant minimum ne peut pas dépasser le montant maximum') + ) + }) + + it('when maxPrice has an invalid format', async () => { + const invalidData = { + minPrice: '10,00', + maxPrice: '150.000', + isLimitCreditSearch: false, + isOnlyFreeOffersSearch: false, + } + + await expect(priceSchema({ initialCredit, currency }).validate(invalidData)).rejects.toEqual( + new ValidationError('Format du prix incorrect. Exemple de format attendu\u00a0: 10,00') + ) + }) + + it('when minPrice has an invalid format', async () => { + const invalidData = { + minPrice: '10,000', + maxPrice: '50,00', + isLimitCreditSearch: false, + isOnlyFreeOffersSearch: false, + } + + await expect(priceSchema({ initialCredit, currency }).validate(invalidData)).rejects.toEqual( + new ValidationError('Format du prix incorrect. Exemple de format attendu\u00a0: 10,00') + ) + }) + }) + + describe('should validate', () => { + it('when minPrice and maxPrice are within range', async () => { + const validData = { + minPrice: '10,00', + maxPrice: '50,00', + isLimitCreditSearch: false, + isOnlyFreeOffersSearch: false, + } + + await expect(priceSchema({ initialCredit, currency }).validate(validData)).resolves.toEqual( + validData + ) + }) + + it('when maxPrice is empty and minPrice is within range', async () => { + const validData = { + minPrice: '10,00', + maxPrice: '', + isLimitCreditSearch: false, + isOnlyFreeOffersSearch: false, + } + + await expect(priceSchema({ initialCredit, currency }).validate(validData)).resolves.toEqual( + validData + ) + }) + + it('when both minPrice and maxPrice are empty', async () => { + const validData = { + minPrice: '', + maxPrice: '', + isLimitCreditSearch: true, + isOnlyFreeOffersSearch: false, + } + + await expect(priceSchema({ initialCredit, currency }).validate(validData)).resolves.toEqual( + validData + ) + }) + }) +}) diff --git a/src/features/search/helpers/schema/priceSchema/priceSchema.ts b/src/features/search/helpers/schema/priceSchema/priceSchema.ts new file mode 100644 index 00000000000..5d7f3ef2065 --- /dev/null +++ b/src/features/search/helpers/schema/priceSchema/priceSchema.ts @@ -0,0 +1,48 @@ +import { boolean, object, string } from 'yup' + +import { Currency } from 'shared/currency/useGetCurrencyToDisplay' + +type PriceValidationParams = { + initialCredit: number + currency: Currency +} + +const PRICE_REGEX = /^\d+(?:[,.]\d{0,2})?$/ +const formatPriceError = `Format du prix incorrect. Exemple de format attendu\u00a0: 10,00` +const minPriceError = `Le montant minimum ne peut pas dépasser le montant maximum` + +const maxPriceError = ({ initialCredit, currency }: PriceValidationParams) => { + return `Le prix indiqué ne doit pas dépasser ${initialCredit}\u00a0${currency}` +} + +const maxPriceValidationSchema = ({ initialCredit, currency }: PriceValidationParams) => + string() + .trim() + .test('validPrice', formatPriceError, (value) => { + if (!value) return true + return PRICE_REGEX.test(value.trim()) + }) + .test('validMaxPrice', maxPriceError({ initialCredit, currency }), (value) => { + if (!value) return true + return Number(value.trim().replaceAll(',', '.')) <= Number(initialCredit) + }) + +const minPriceWithConditions = ({ initialCredit, currency }: PriceValidationParams) => + maxPriceValidationSchema({ initialCredit, currency }).when(['maxPrice'], { + is: (maxPrice: string) => !!maxPrice && maxPrice.trim().length > 0, // Vérifie si maxPrice est non vide + then: (schema) => + schema.test('validMinPrice', minPriceError, (value, schema) => { + if (!value) return true + const minPrice = Number(value.trim().replace(',', '.')) + const maxPrice = Number(schema.parent.maxPrice.trim().replace(',', '.')) + return minPrice <= maxPrice + }), + }) + +export const priceSchema = ({ initialCredit, currency }: PriceValidationParams) => + object().shape({ + minPrice: minPriceWithConditions({ initialCredit, currency }), + maxPrice: maxPriceValidationSchema({ initialCredit, currency }), + isLimitCreditSearch: boolean(), + isOnlyFreeOffersSearch: boolean(), + }) diff --git a/src/features/search/pages/SearchFilter/SearchFilter.tsx b/src/features/search/pages/SearchFilter/SearchFilter.tsx index 8145994b6b6..40d0013e25b 100644 --- a/src/features/search/pages/SearchFilter/SearchFilter.tsx +++ b/src/features/search/pages/SearchFilter/SearchFilter.tsx @@ -16,16 +16,21 @@ import { useNavigateToSearch } from 'features/search/helpers/useNavigateToSearch import { useSync } from 'features/search/helpers/useSync/useSync' import { LocationFilter } from 'features/search/types' import { analytics } from 'libs/analytics' +import { useGetPacificFrancToEuroRate } from 'libs/firebase/firestore/exchangeRates/useGetPacificFrancToEuroRate' import { useFunctionOnce } from 'libs/hooks' import { useLocation } from 'libs/location' import { LocationMode } from 'libs/location/types' import { SuggestedPlace } from 'libs/place/types' +import { useGetCurrencyToDisplay } from 'shared/currency/useGetCurrencyToDisplay' import { Li } from 'ui/components/Li' import { VerticalUl } from 'ui/components/Ul' import { SecondaryPageWithBlurHeader } from 'ui/pages/SecondaryPageWithBlurHeader' import { Spacer } from 'ui/theme' export const SearchFilter: React.FC = () => { + const currency = useGetCurrencyToDisplay() + const euroToPacificFrancRate = useGetPacificFrancToEuroRate() + const { disabilities, setDisabilities } = useAccessibilityFiltersContext() const routes = useNavigationState((state) => state?.routes) const currentRoute = routes?.[routes?.length - 1]?.name @@ -45,17 +50,13 @@ export const SearchFilter: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps const oldSearchState = useMemo(() => searchState, []) + const onGoBack = useCallback(() => { navigateToSearch(oldSearchState, oldAccessibilityFilter) }, [navigateToSearch, oldSearchState, oldAccessibilityFilter]) const onSearchPress = useCallback(() => { - navigateToSearch( - { - ...searchState, - }, - disabilities - ) + navigateToSearch({ ...searchState }, disabilities) }, [navigateToSearch, searchState, disabilities]) const onResetPress = useCallback(() => { @@ -119,14 +120,7 @@ export const SearchFilter: React.FC = () => { return isBeneficiary && hasRemainingCredit }, [user?.isBeneficiary, user?.domainsCredit?.all?.remaining]) - const shouldDisplayCloseButton = isMobileViewport - - const sectionItems = [Section.Category] - if (hasDuoOfferToggle) sectionItems.push(Section.OfferDuo) - sectionItems.push(Section.Venue) - sectionItems.push(Section.Price) - sectionItems.push(Section.DateHour) - sectionItems.push(Section.Accessibility) + const onClose = isMobileViewport ? onGoBack : undefined return ( @@ -135,13 +129,30 @@ export const SearchFilter: React.FC = () => { onGoBack={onGoBack} scrollViewProps={{ keyboardShouldPersistTaps: 'always' }}> - {sectionItems.map((SectionItem, index) => { - return ( - - - - ) - })} + + + + {hasDuoOfferToggle ? ( + + + + ) : null} + + + + + + + + + + + + @@ -157,7 +168,7 @@ export const SearchFilter: React.FC = () => { const SectionWrapper: React.FunctionComponent<{ children: React.JSX.Element - isFirstSectionItem: boolean + isFirstSectionItem?: boolean }> = ({ children, isFirstSectionItem = false }) => { return ( diff --git a/src/features/search/pages/modals/PriceModal/PriceModal.native.test.tsx b/src/features/search/pages/modals/PriceModal/PriceModal.native.test.tsx index efe0f1f40f4..eb878c5811d 100644 --- a/src/features/search/pages/modals/PriceModal/PriceModal.native.test.tsx +++ b/src/features/search/pages/modals/PriceModal/PriceModal.native.test.tsx @@ -9,7 +9,7 @@ import { SearchState } from 'features/search/types' import { beneficiaryUser } from 'fixtures/user' import { setFeatureFlags } from 'libs/firebase/firestore/featureFlags/__tests__/setFeatureFlags' import { convertCentsToEuros } from 'libs/parsers/pricesConversion' -import { fireEvent, render, act, waitFor, screen } from 'tests/utils' +import { render, waitFor, screen, userEvent } from 'tests/utils' import { PriceModal, PriceModalProps } from './PriceModal' @@ -48,6 +48,9 @@ jest.mock('react-native/Libraries/Animated/createAnimatedComponent', () => { } }) +const user = userEvent.setup() +jest.useFakeTimers() + describe('', () => { beforeEach(() => { setFeatureFlags() @@ -79,10 +82,10 @@ describe('', () => { renderSearchPrice() const minPriceInput = screen.getByPlaceholderText('0') - fireEvent.changeText(minPriceInput, '5') + await user.type(minPriceInput, '5') const resetButton = screen.getByText('Réinitialiser') - await act(async () => fireEvent.press(resetButton)) + await user.press(resetButton) expect(minPriceInput.props.value).toStrictEqual('') }) @@ -91,10 +94,10 @@ describe('', () => { renderSearchPrice() const maxPriceInput = screen.getByPlaceholderText('80') - fireEvent.changeText(maxPriceInput, '20') + await user.type(maxPriceInput, '20') const resetButton = screen.getByText('Réinitialiser') - await act(async () => fireEvent.press(resetButton)) + await user.press(resetButton) expect(maxPriceInput.props.value).toStrictEqual('') }) @@ -103,7 +106,7 @@ describe('', () => { renderSearchPrice() const resetButton = screen.getByText('Réinitialiser') - await act(async () => fireEvent.press(resetButton)) + await user.press(resetButton) const toggleLimitCreditSearch = screen.getByTestId('Interrupteur limitCreditSearch') @@ -114,7 +117,7 @@ describe('', () => { renderSearchPrice() const resetButton = screen.getByText('Réinitialiser') - await act(async () => fireEvent.press(resetButton)) + await user.press(resetButton) const toggleOnlyFreeOffersSearch = screen.getByTestId('Interrupteur onlyFreeOffers') @@ -130,7 +133,7 @@ describe('', () => { const minPriceInput = screen.getByPlaceholderText('0') const resetButton = screen.getByText('Réinitialiser') - await act(async () => fireEvent.press(resetButton)) + await user.press(resetButton) expect(minPriceInput.props.value).toStrictEqual('') }) @@ -142,7 +145,7 @@ describe('', () => { const minPriceInput = screen.getByPlaceholderText('0') const previousButton = screen.getByTestId('Fermer') - await act(async () => fireEvent.press(previousButton)) + await user.press(previousButton) expect(minPriceInput.props.value).toStrictEqual('5') }) @@ -154,7 +157,7 @@ describe('', () => { const maxPriceInput = screen.getByPlaceholderText('80') const resetButton = screen.getByText('Réinitialiser') - await act(async () => fireEvent.press(resetButton)) + await user.press(resetButton) expect(maxPriceInput.props.value).toStrictEqual('') }) @@ -166,7 +169,7 @@ describe('', () => { const maxPriceInput = screen.getByPlaceholderText('80') const previousButton = screen.getByTestId('Fermer') - await act(async () => fireEvent.press(previousButton)) + await user.press(previousButton) expect(maxPriceInput.props.value).toStrictEqual('15') }) @@ -176,7 +179,7 @@ describe('', () => { renderSearchPrice() const resetButton = screen.getByText('Réinitialiser') - await act(async () => fireEvent.press(resetButton)) + await user.press(resetButton) const toggleLimitCreditSearch = screen.getByTestId('Interrupteur limitCreditSearch') @@ -188,7 +191,7 @@ describe('', () => { renderSearchPrice() const previousButton = screen.getByTestId('Fermer') - await act(async () => fireEvent.press(previousButton)) + await user.press(previousButton) const toggleLimitCreditSearch = screen.getByTestId('Interrupteur limitCreditSearch') @@ -200,7 +203,7 @@ describe('', () => { renderSearchPrice() const resetButton = screen.getByText('Réinitialiser') - await act(async () => fireEvent.press(resetButton)) + await user.press(resetButton) const toggleOnlyFreeOffersSearch = screen.getByTestId('Interrupteur onlyFreeOffers') @@ -212,7 +215,7 @@ describe('', () => { renderSearchPrice() const previousButton = screen.getByTestId('Fermer') - await act(async () => fireEvent.press(previousButton)) + await user.press(previousButton) const toggleOnlyFreeOffersSearch = screen.getByTestId('Interrupteur onlyFreeOffers') @@ -224,7 +227,7 @@ describe('', () => { renderSearchPrice() const toggleLimitCreditSearch = screen.getByTestId('Interrupteur limitCreditSearch') - await act(async () => fireEvent.press(toggleLimitCreditSearch)) + await user.press(toggleLimitCreditSearch) const maxPriceInput = screen.getByPlaceholderText('80') @@ -235,7 +238,7 @@ describe('', () => { renderSearchPrice() const toggleLimitCreditSearch = screen.getByTestId('Interrupteur limitCreditSearch') - await act(async () => fireEvent.press(toggleLimitCreditSearch)) + await user.press(toggleLimitCreditSearch) const maxPriceInput = screen.getByPlaceholderText('80') @@ -246,8 +249,8 @@ describe('', () => { renderSearchPrice() const toggleLimitCreditSearch = screen.getByTestId('Interrupteur limitCreditSearch') - fireEvent.press(toggleLimitCreditSearch) - await act(async () => fireEvent.press(toggleLimitCreditSearch)) + await user.press(toggleLimitCreditSearch) + await user.press(toggleLimitCreditSearch) const maxPriceInput = screen.getByPlaceholderText('80') @@ -259,7 +262,7 @@ describe('', () => { renderSearchPrice() const toggleLimitCreditSearch = screen.getByTestId('Interrupteur limitCreditSearch') - await act(async () => fireEvent.press(toggleLimitCreditSearch)) + await user.press(toggleLimitCreditSearch) const maxPriceInput = screen.getByPlaceholderText('80') @@ -271,8 +274,8 @@ describe('', () => { renderSearchPrice() const toggleLimitCreditSearch = screen.getByTestId('Interrupteur limitCreditSearch') - fireEvent.press(toggleLimitCreditSearch) - await act(async () => fireEvent.press(toggleLimitCreditSearch)) + await user.press(toggleLimitCreditSearch) + await user.press(toggleLimitCreditSearch) const maxPriceInput = screen.getByPlaceholderText('80') @@ -283,12 +286,12 @@ describe('', () => { renderSearchPrice() const toggleLimitCreditSearch = screen.getByTestId('Interrupteur limitCreditSearch') - await act(async () => fireEvent.press(toggleLimitCreditSearch)) + await user.press(toggleLimitCreditSearch) expect(toggleLimitCreditSearch.props.accessibilityState.checked).toStrictEqual(true) const toggleOnlyFreeOffersSearch = screen.getByTestId('Interrupteur onlyFreeOffers') - await act(async () => fireEvent.press(toggleOnlyFreeOffersSearch)) + await user.press(toggleOnlyFreeOffersSearch) expect(toggleLimitCreditSearch.props.accessibilityState.checked).toStrictEqual(false) }) @@ -297,12 +300,12 @@ describe('', () => { renderSearchPrice() const toggleOnlyFreeOffersSearch = screen.getByTestId('Interrupteur onlyFreeOffers') - await act(async () => fireEvent.press(toggleOnlyFreeOffersSearch)) + await user.press(toggleOnlyFreeOffersSearch) expect(toggleOnlyFreeOffersSearch.props.accessibilityState.checked).toStrictEqual(true) const toggleLimitCreditSearch = screen.getByTestId('Interrupteur limitCreditSearch') - await act(async () => fireEvent.press(toggleLimitCreditSearch)) + await user.press(toggleLimitCreditSearch) expect(toggleOnlyFreeOffersSearch.props.accessibilityState.checked).toStrictEqual(false) }) @@ -311,10 +314,10 @@ describe('', () => { renderSearchPrice() const minPriceInput = screen.getByPlaceholderText('0') - fireEvent.changeText(minPriceInput, '5') + await user.type(minPriceInput, '5') const toggleOnlyFreeOffersSearch = screen.getByTestId('Interrupteur onlyFreeOffers') - await act(async () => fireEvent.press(toggleOnlyFreeOffersSearch)) + await user.press(toggleOnlyFreeOffersSearch) expect(minPriceInput.props.value).toStrictEqual('0') }) @@ -324,8 +327,8 @@ describe('', () => { renderSearchPrice() const toggleOnlyFreeOffersSearch = screen.getByTestId('Interrupteur onlyFreeOffers') - fireEvent.press(toggleOnlyFreeOffersSearch) - await act(async () => fireEvent.press(toggleOnlyFreeOffersSearch)) + await user.press(toggleOnlyFreeOffersSearch) + await user.press(toggleOnlyFreeOffersSearch) const minPriceInput = screen.getByPlaceholderText('0') @@ -337,8 +340,8 @@ describe('', () => { renderSearchPrice() const toggleOnlyFreeOffersSearch = screen.getByTestId('Interrupteur onlyFreeOffers') - fireEvent.press(toggleOnlyFreeOffersSearch) - await act(async () => fireEvent.press(toggleOnlyFreeOffersSearch)) + await user.press(toggleOnlyFreeOffersSearch) + await user.press(toggleOnlyFreeOffersSearch) const minPriceInput = screen.getByPlaceholderText('0') @@ -350,8 +353,8 @@ describe('', () => { renderSearchPrice() const toggleOnlyFreeOffersSearch = screen.getByTestId('Interrupteur onlyFreeOffers') - fireEvent.press(toggleOnlyFreeOffersSearch) - await act(async () => fireEvent.press(toggleOnlyFreeOffersSearch)) + await user.press(toggleOnlyFreeOffersSearch) + await user.press(toggleOnlyFreeOffersSearch) const maxPriceInput = screen.getByPlaceholderText('80') @@ -363,8 +366,8 @@ describe('', () => { renderSearchPrice() const toggleOnlyFreeOffersSearch = screen.getByTestId('Interrupteur onlyFreeOffers') - fireEvent.press(toggleOnlyFreeOffersSearch) - await act(async () => fireEvent.press(toggleOnlyFreeOffersSearch)) + await user.press(toggleOnlyFreeOffersSearch) + await user.press(toggleOnlyFreeOffersSearch) const maxPriceInput = screen.getByPlaceholderText('80') @@ -377,7 +380,7 @@ describe('', () => { const minPriceInput = screen.getByPlaceholderText('0') const toggleOnlyFreeOffersSearch = screen.getByTestId('Interrupteur onlyFreeOffers') - await act(async () => fireEvent.press(toggleOnlyFreeOffersSearch)) + await user.press(toggleOnlyFreeOffersSearch) expect(minPriceInput.props.disabled).toStrictEqual(true) }) @@ -386,10 +389,10 @@ describe('', () => { renderSearchPrice() const maxPriceInput = screen.getByPlaceholderText('80') - fireEvent.changeText(maxPriceInput, '0') + await user.type(maxPriceInput, '5') const toggleOnlyFreeOffersSearch = screen.getByTestId('Interrupteur onlyFreeOffers') - await act(async () => fireEvent.press(toggleOnlyFreeOffersSearch)) + await user.press(toggleOnlyFreeOffersSearch) expect(maxPriceInput.props.value).toStrictEqual('0') }) @@ -400,7 +403,7 @@ describe('', () => { const maxPriceInput = screen.getByPlaceholderText('80') const toggleOnlyFreeOffersSearch = screen.getByTestId('Interrupteur onlyFreeOffers') - await act(async () => fireEvent.press(toggleOnlyFreeOffersSearch)) + await user.press(toggleOnlyFreeOffersSearch) expect(maxPriceInput.props.disabled).toStrictEqual(true) }) @@ -423,7 +426,7 @@ describe('', () => { renderSearchPrice() const minPriceInput = screen.getByPlaceholderText('0') - await act(async () => fireEvent.changeText(minPriceInput, '10,559')) + await user.type(minPriceInput, '10,559') const inputError = screen.getByText( `Format du prix incorrect. Exemple de format attendu\u00a0: 10,00` @@ -436,7 +439,7 @@ describe('', () => { renderSearchPrice() const maxPriceInput = screen.getByPlaceholderText('80') - await act(async () => fireEvent.changeText(maxPriceInput, '10,559')) + await user.type(maxPriceInput, '10,559') const inputError = screen.getByText( `Format du prix incorrect. Exemple de format attendu\u00a0: 10,00` @@ -479,7 +482,7 @@ describe('', () => { expect(searchButton).toBeEnabled() }) - await act(async () => fireEvent.press(searchButton)) + await user.press(searchButton) expect(mockHideModal).toHaveBeenCalledTimes(1) }) @@ -492,7 +495,7 @@ describe('', () => { }) const previousButton = screen.getByTestId('Fermer') - fireEvent.press(previousButton) + await user.press(previousButton) expect(mockHideModal).toHaveBeenCalledTimes(1) }) @@ -504,11 +507,11 @@ describe('', () => { const minPriceInput = screen.getByPlaceholderText('0') const onlyFreeOffersToggle = screen.getByTestId('Interrupteur onlyFreeOffers') - await act(async () => fireEvent.changeText(minPriceInput, '9999')) + await user.type(minPriceInput, '9999') expect(screen.getByText('Le prix indiqué ne doit pas dépasser 80\u00a0€')).toBeOnTheScreen() - await act(async () => fireEvent.press(onlyFreeOffersToggle)) + await user.press(onlyFreeOffersToggle) expect( screen.queryByText('Le prix indiqué ne doit pas dépasser 80\u00a0€') @@ -519,13 +522,12 @@ describe('', () => { renderSearchPrice() const maxPriceInput = screen.getByPlaceholderText('80') - const onlyFreeOffersToggle = screen.getByTestId('Interrupteur onlyFreeOffers') - - await act(async () => fireEvent.changeText(maxPriceInput, '9999')) + await user.type(maxPriceInput, '9999') expect(screen.getByText('Le prix indiqué ne doit pas dépasser 80\u00a0€')).toBeOnTheScreen() - await act(async () => fireEvent.press(onlyFreeOffersToggle)) + const onlyFreeOffersToggle = screen.getByTestId('Interrupteur onlyFreeOffers') + await user.press(onlyFreeOffersToggle) expect( screen.queryByText('Le prix indiqué ne doit pas dépasser 80\u00a0€') @@ -536,13 +538,12 @@ describe('', () => { renderSearchPrice() const maxPriceInput = screen.getByPlaceholderText('80') - const limitCreditSearchToggle = screen.getByTestId('Interrupteur limitCreditSearch') - - await act(async () => fireEvent.changeText(maxPriceInput, '9999')) + await user.type(maxPriceInput, '9999') expect(screen.getByText('Le prix indiqué ne doit pas dépasser 80\u00a0€')).toBeOnTheScreen() - await act(async () => fireEvent.press(limitCreditSearchToggle)) + const limitCreditSearchToggle = screen.getByTestId('Interrupteur limitCreditSearch') + await user.press(limitCreditSearchToggle) expect( screen.queryByText('Le prix indiqué ne doit pas dépasser 80\u00a0€') @@ -686,17 +687,17 @@ describe('', () => { }) const maxPriceInput = screen.getByPlaceholderText('80') - fireEvent.changeText(maxPriceInput, '50') + await user.type(maxPriceInput, '50') - await waitFor(() => { - expect(screen.getByText('Appliquer le filtre')).toBeEnabled() - }) + expect(screen.getByText('Appliquer le filtre')).toBeEnabled() const searchButton = screen.getByText('Appliquer le filtre') - await act(async () => fireEvent.press(searchButton)) + await user.press(searchButton) const expectedSearchParams: SearchState = { ...searchState, + defaultMinPrice: '', + defaultMaxPrice: '50', maxPrice: '50', } @@ -713,18 +714,20 @@ describe('', () => { renderSearchPrice() const minPriceInput = screen.getByPlaceholderText('0') - await act(async () => fireEvent.changeText(minPriceInput, '5')) + await user.type(minPriceInput, '5') const maxPriceInput = screen.getByPlaceholderText('80') - await act(async () => fireEvent.changeText(maxPriceInput, '20')) + await user.type(maxPriceInput, '20') const searchButton = screen.getByText('Rechercher') - await act(async () => fireEvent.press(searchButton)) + await user.press(searchButton) const expectedSearchParams = { ...mockSearchState, minPrice: '5', maxPrice: '20', + defaultMinPrice: '5', + defaultMaxPrice: '20', } expect(mockDispatch).toHaveBeenCalledWith({ @@ -737,18 +740,20 @@ describe('', () => { renderSearchPrice() const minPriceInput = screen.getByPlaceholderText('0') - await act(async () => fireEvent.changeText(minPriceInput, '0')) + await user.type(minPriceInput, '0') const maxPriceInput = screen.getByPlaceholderText('80') - await act(async () => fireEvent.changeText(maxPriceInput, '0')) + await user.type(maxPriceInput, '0') const searchButton = screen.getByText('Rechercher') - await act(async () => fireEvent.press(searchButton)) + await user.press(searchButton) const expectedSearchParams = { ...mockSearchState, minPrice: '0', maxPrice: '0', + defaultMinPrice: '0', + defaultMaxPrice: '0', offerIsFree: true, } @@ -762,16 +767,18 @@ describe('', () => { renderSearchPrice() const toggleOnlyFreeOffersSearch = screen.getByTestId('Interrupteur onlyFreeOffers') - await act(async () => fireEvent.press(toggleOnlyFreeOffersSearch)) + await user.press(toggleOnlyFreeOffersSearch) const searchButton = screen.getByText('Rechercher') - await act(async () => fireEvent.press(searchButton)) + await user.press(searchButton) const expectedSearchParams = { ...mockSearchState, offerIsFree: true, minPrice: '0', maxPrice: '0', + defaultMinPrice: '0', + defaultMaxPrice: '0', } expect(mockDispatch).toHaveBeenCalledWith({ @@ -784,15 +791,17 @@ describe('', () => { renderSearchPrice() const maxPriceInput = screen.getByPlaceholderText('80') - await act(async () => fireEvent.changeText(maxPriceInput, '0')) + await user.type(maxPriceInput, '0') const searchButton = screen.getByText('Rechercher') - await act(async () => fireEvent.press(searchButton)) + await user.press(searchButton) const expectedSearchParams = { ...mockSearchState, offerIsFree: true, maxPrice: '0', + defaultMaxPrice: '0', + defaultMinPrice: '', } expect(mockDispatch).toHaveBeenCalledWith({ @@ -805,14 +814,18 @@ describe('', () => { renderSearchPrice() const minPriceInput = screen.getByPlaceholderText('0') - await act(async () => fireEvent.changeText(minPriceInput, '1')) + await user.type(minPriceInput, '1') const searchButton = screen.getByText('Rechercher') - await act(async () => fireEvent.press(searchButton)) + await user.press(searchButton) const expectedSearchParams = { ...mockSearchState, minPrice: '1', + maxPrice: undefined, + defaultMinPrice: '1', + defaultMaxPrice: '', + maxPossiblePrice: '80', } expect(mockDispatch).toHaveBeenCalledWith({ @@ -835,7 +848,7 @@ describe('', () => { }) const closeButton = screen.getByTestId('Fermer') - fireEvent.press(closeButton) + await user.press(closeButton) expect(mockOnClose).toHaveBeenCalledTimes(1) }) @@ -848,7 +861,7 @@ describe('', () => { }) const closeButton = screen.getByTestId('Fermer') - fireEvent.press(closeButton) + await user.press(closeButton) expect(mockOnClose).not.toHaveBeenCalled() }) diff --git a/src/features/search/pages/modals/PriceModal/PriceModal.tsx b/src/features/search/pages/modals/PriceModal/PriceModal.tsx index 3a8c4872299..b686c42b077 100644 --- a/src/features/search/pages/modals/PriceModal/PriceModal.tsx +++ b/src/features/search/pages/modals/PriceModal/PriceModal.tsx @@ -1,34 +1,33 @@ import { yupResolver } from '@hookform/resolvers/yup' import React, { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react' import { Controller, useForm } from 'react-hook-form' -import { View } from 'react-native' import { useTheme } from 'styled-components' +import styled from 'styled-components/native' import { v4 as uuidv4 } from 'uuid' import { useAuthContext } from 'features/auth/context/AuthContext' import { FilterSwitchWithLabel } from 'features/search/components/FilterSwitchWithLabel/FilterSwitchWithLabel' +import { PriceInputController } from 'features/search/components/PriceInputController/PriceInputController' import { SearchCustomModalHeader } from 'features/search/components/SearchCustomModalHeader' import { SearchFixedModalBottom } from 'features/search/components/SearchFixedModalBottom' import { useSearch } from 'features/search/context/SearchWrapper' import { FilterBehaviour } from 'features/search/enums' import { MAX_PRICE_IN_CENTS } from 'features/search/helpers/reducer.helpers' -import { makeSearchPriceSchema } from 'features/search/helpers/schema/makeSearchPriceSchema/makeSearchPriceSchema' +import { priceSchema } from 'features/search/helpers/schema/priceSchema/priceSchema' import { SearchState } from 'features/search/types' import { useGetPacificFrancToEuroRate } from 'libs/firebase/firestore/exchangeRates/useGetPacificFrancToEuroRate' -import { useFormatCurrencyFromCents } from 'libs/parsers/formatCurrencyFromCents' -import { convertCentsToEuros, RoundingMode } from 'libs/parsers/pricesConversion' -import { useGetCurrencyToDisplay } from 'shared/currency/useGetCurrencyToDisplay' +import { formatCurrencyFromCents } from 'libs/parsers/formatCurrencyFromCents' +import { formatCurrencyFromCentsWithoutCurrencySymbol } from 'libs/parsers/formatCurrencyFromCentsWithoutCurrencySymbol' +import { Currency, useGetCurrencyToDisplay } from 'shared/currency/useGetCurrencyToDisplay' import { useAvailableCredit } from 'shared/user/useAvailableCredit' import { InfoBanner } from 'ui/components/banners/InfoBanner' import { Form } from 'ui/components/Form' -import { InputError } from 'ui/components/inputs/InputError' -import { TextInput } from 'ui/components/inputs/TextInput' import { useForHeightKeyboardEvents } from 'ui/components/keyboard/useKeyboardEvents' import { AppModal } from 'ui/components/modals/AppModal' import { Separator } from 'ui/components/Separator' import { Close } from 'ui/svg/icons/Close' import { Error } from 'ui/svg/icons/Error' -import { getSpacing, Spacer } from 'ui/theme' +import { Spacer, getSpacing } from 'ui/theme' type PriceModalFormData = { minPrice: string @@ -50,6 +49,10 @@ const titleId = uuidv4() const minPriceInputId = uuidv4() const maxPriceInputId = uuidv4() +const getConversionRate = (currency: Currency, euroToPacificFrancRate: number) => { + return currency === Currency.PACIFIC_FRANC_SHORT ? euroToPacificFrancRate : 1 +} + export const PriceModal: FunctionComponent = ({ title, accessibilityLabel, @@ -58,27 +61,40 @@ export const PriceModal: FunctionComponent = ({ filterBehaviour, onClose, }) => { - const currency = useGetCurrencyToDisplay() const currencyFull = useGetCurrencyToDisplay('full') + const currency = useGetCurrencyToDisplay() const euroToPacificFrancRate = useGetPacificFrancToEuroRate() + const conversionRate = getConversionRate(currency, euroToPacificFrancRate) const { searchState, dispatch } = useSearch() const { isLoggedIn, user } = useAuthContext() const availableCredit = useAvailableCredit()?.amount ?? 0 - const formatAvailableCredit = convertCentsToEuros(availableCredit, RoundingMode.FLOORED) - const formatAvailableCreditWithCurrency = useFormatCurrencyFromCents(availableCredit) + const formatAvailableCredit = formatCurrencyFromCentsWithoutCurrencySymbol( + availableCredit, + currency, + euroToPacificFrancRate + ) + const formatAvailableCreditWithCurrency = formatCurrencyFromCents( + availableCredit, + currency, + euroToPacificFrancRate + ) const bannerTitle = `Il te reste ${formatAvailableCreditWithCurrency} sur ton pass Culture.` const initialCredit = user?.domainsCredit?.all?.initial ?? MAX_PRICE_IN_CENTS - const formatInitialCredit = convertCentsToEuros(initialCredit, RoundingMode.FLOORED) - const formatInitialCreditWithCurrency = useFormatCurrencyFromCents(initialCredit) - - const searchPriceSchema = makeSearchPriceSchema( - String(formatInitialCredit), + const formatInitialCredit = formatCurrencyFromCentsWithoutCurrencySymbol( + initialCredit, currency, euroToPacificFrancRate ) + const formatInitialCreditWithCurrency = formatCurrencyFromCents( + initialCredit, + currency, + euroToPacificFrancRate + ) + + const searchPriceSchema = priceSchema({ initialCredit: formatInitialCredit, currency }) const isLimitCreditSearchDefaultValue = Number(searchState?.maxPrice) === formatAvailableCredit const isLoggedInAndBeneficiary = isLoggedIn && user?.isBeneficiary @@ -91,28 +107,41 @@ export const PriceModal: FunctionComponent = ({ useForHeightKeyboardEvents(setKeyboardHeight) function search(values: PriceModalFormData) { + const transformedValues = { + ...values, + minPrice: values.minPrice ? String(Number(values.minPrice) * conversionRate) : '', + maxPrice: values.maxPrice ? String(Number(values.maxPrice) * conversionRate) : '', + } + const offerIsFree = - values.isOnlyFreeOffersSearch || - (values.maxPrice === '0' && (values.minPrice === '' || values.minPrice === '0')) + transformedValues.isOnlyFreeOffersSearch || + (transformedValues.maxPrice === '0' && + (transformedValues.minPrice === '' || transformedValues.minPrice === '0')) + let additionalSearchState: SearchState = { ...searchState, priceRange: null, minPrice: undefined, maxPrice: undefined, + defaultMinPrice: values.minPrice, + defaultMaxPrice: values.maxPrice, offerIsFree, } - if (values.minPrice) { - additionalSearchState = { ...additionalSearchState, minPrice: values.minPrice } + if (transformedValues.minPrice) { + additionalSearchState = { + ...additionalSearchState, + minPrice: transformedValues.minPrice, + } } - if (values.maxPrice) { + + if (transformedValues.maxPrice) { additionalSearchState = { ...additionalSearchState, - maxPrice: values.maxPrice, + maxPrice: transformedValues.maxPrice, maxPossiblePrice: undefined, } } else { - // Only the offers that can be reserved by the beneficiary user additionalSearchState = { ...additionalSearchState, maxPossiblePrice: String(formatInitialCredit), @@ -125,17 +154,12 @@ export const PriceModal: FunctionComponent = ({ const initialFormValues = useMemo(() => { return { - minPrice: searchState?.minPrice ?? '', - maxPrice: searchState?.maxPrice ?? '', + minPrice: searchState?.defaultMinPrice ?? '', + maxPrice: searchState?.defaultMaxPrice ?? '', isLimitCreditSearch: isLimitCreditSearchDefaultValue, isOnlyFreeOffersSearch: isOnlyFreeOffersSearchDefaultValue, } - }, [ - isLimitCreditSearchDefaultValue, - isOnlyFreeOffersSearchDefaultValue, - searchState?.maxPrice, - searchState?.minPrice, - ]) + }, [isLimitCreditSearchDefaultValue, isOnlyFreeOffersSearchDefaultValue, searchState]) const { handleSubmit, @@ -169,6 +193,7 @@ export const PriceModal: FunctionComponent = ({ } const maxPrice = searchState?.maxPrice === '0' ? '' : searchState?.maxPrice ?? '' const minPrice = searchState?.minPrice === '0' ? '' : searchState?.minPrice ?? '' + setValue('maxPrice', maxPrice) setValue('minPrice', minPrice) trigger(['minPrice', 'maxPrice']) @@ -227,7 +252,6 @@ export const PriceModal: FunctionComponent = ({ }, [reset]) const disabled = !isValid || (!isValidating && isSubmitting) - const isKeyboardOpen = keyboardHeight > 0 const shouldDisplayBackButton = filterBehaviour === FilterBehaviour.APPLY_WITHOUT_SEARCHING @@ -262,10 +286,10 @@ export const PriceModal: FunctionComponent = ({ {isLoggedInAndBeneficiary ? ( - - + + - + ) : null} = ({ /> )} /> - - - + {isLoggedInAndBeneficiary ? ( = ({ label="Limiter la recherche à mon crédit" testID="limitCreditSearch" /> - - - + )} /> ) : null} - ( - - 0} - keyboardType="numeric" - label={`Prix minimum (en\u00a0${currencyFull})`} - value={value} - onChangeText={onChange} - onBlur={onBlur} - textContentType="none" // disable autofill on iOS - accessibilityDescribedBy={minPriceInputId} - testID="Entrée pour le prix minimum" - placeholder="0" - disabled={getValues('isOnlyFreeOffersSearch')} - /> - - - )} + label={`Prix minimum (en\u00a0${currencyFull})`} + placeholder="0" + accessibilityId={minPriceInputId} + testID="Entrée pour le prix minimum" + isDisabled={getValues('isOnlyFreeOffersSearch')} /> - ( - - 0} - keyboardType="numeric" - label={`Prix maximum (en\u00a0${currencyFull})`} - value={value} - onChangeText={(value) => { - onChange(value) - trigger('minPrice') - }} - onBlur={onBlur} - textContentType="none" // disable autofill on iOS - accessibilityDescribedBy={maxPriceInputId} - testID="Entrée pour le prix maximum" - rightLabel={`max\u00a0: ${formatInitialCreditWithCurrency}`} - placeholder={`${formatInitialCredit}`} - disabled={getValues('isLimitCreditSearch') || getValues('isOnlyFreeOffersSearch')} - /> - - - )} + label={`Prix maximum (en\u00a0${currencyFull})`} + placeholder={`${formatInitialCredit}`} + rightLabel={`max\u00a0: ${formatInitialCreditWithCurrency}`} + accessibilityId={maxPriceInputId} + testID="Entrée pour le prix maximum" + isDisabled={getValues('isLimitCreditSearch') || getValues('isOnlyFreeOffersSearch')} /> {isKeyboardOpen ? : null} ) } + +const StyledSeparator = styled(Separator.Horizontal)({ + marginVertical: getSpacing(6), +}) diff --git a/src/features/search/types.ts b/src/features/search/types.ts index 048f882292a..16b49ea9f09 100644 --- a/src/features/search/types.ts +++ b/src/features/search/types.ts @@ -54,6 +54,8 @@ export interface SearchState { query: string minPrice?: string maxPrice?: string + defaultMinPrice?: string + defaultMaxPrice?: string searchId?: string maxPossiblePrice?: string isAutocomplete?: boolean diff --git a/src/libs/parsers/formatCurrencyFromCents.native.test.ts b/src/libs/parsers/formatCurrencyFromCents.native.test.ts index c1c1d051f3a..551646c6f6c 100644 --- a/src/libs/parsers/formatCurrencyFromCents.native.test.ts +++ b/src/libs/parsers/formatCurrencyFromCents.native.test.ts @@ -18,8 +18,6 @@ describe('formatCurrencyFromCents()', () => { ${-1190} | ${Currency.EURO} | ${'-11,90\u00a0€'} ${1199} | ${Currency.EURO} | ${'11,99\u00a0€'} ${-1199} | ${Currency.EURO} | ${'-11,99\u00a0€'} - ${1199.6} | ${Currency.EURO} | ${'12,00\u00a0€'} - ${-1199.6} | ${Currency.EURO} | ${'-12,00\u00a0€'} ${100} | ${Currency.PACIFIC_FRANC_SHORT} | ${'120\u00a0F'} `( 'formatCurrencyFromCents($priceInCents) \t= $expected without format price options', @@ -44,8 +42,6 @@ describe('formatCurrencyFromCents()', () => { ${-1190} | ${Currency.EURO} | ${'-11,90\u00a0€'} ${1199} | ${Currency.EURO} | ${'11,99\u00a0€'} ${-1199} | ${Currency.EURO} | ${'-11,99\u00a0€'} - ${1199.6} | ${Currency.EURO} | ${'12,00\u00a0€'} - ${-1199.6} | ${Currency.EURO} | ${'-12,00\u00a0€'} ${100} | ${Currency.PACIFIC_FRANC_SHORT} | ${'120\u00a0F'} `( 'formatCurrencyFromCents($priceInCents) \t= $expected with format price options', diff --git a/src/libs/parsers/formatCurrencyFromCentsWithoutCurrencySymbol.native.test.ts b/src/libs/parsers/formatCurrencyFromCentsWithoutCurrencySymbol.native.test.ts new file mode 100644 index 00000000000..75b0bc5ce3a --- /dev/null +++ b/src/libs/parsers/formatCurrencyFromCentsWithoutCurrencySymbol.native.test.ts @@ -0,0 +1,34 @@ +import { DEFAULT_PACIFIC_FRANC_TO_EURO_RATE } from 'libs/firebase/firestore/exchangeRates/useGetPacificFrancToEuroRate' +import { Currency } from 'shared/currency/useGetCurrencyToDisplay' + +import { formatCurrencyFromCentsWithoutCurrencySymbol } from './formatCurrencyFromCentsWithoutCurrencySymbol' + +describe('formatCurrencyFromCentsWithoutCurrencySymbol()', () => { + it.each` + priceInCents | currency | expected + ${0} | ${Currency.EURO} | ${0} + ${100} | ${Currency.EURO} | ${1} + ${500} | ${Currency.EURO} | ${5} + ${-500} | ${Currency.EURO} | ${-5} + ${1050} | ${Currency.EURO} | ${10.5} + ${-1050} | ${Currency.EURO} | ${-10.5} + ${1110} | ${Currency.EURO} | ${11.1} + ${-1110} | ${Currency.EURO} | ${-11.1} + ${1190} | ${Currency.EURO} | ${11.9} + ${-1190} | ${Currency.EURO} | ${-11.9} + ${1199} | ${Currency.EURO} | ${11.99} + ${-1199} | ${Currency.EURO} | ${-11.99} + ${100} | ${Currency.PACIFIC_FRANC_SHORT} | ${120} + `( + 'formatCurrencyFromCentsWithoutCurrencySymbol($priceInCents) = $expected without format price options', + ({ priceInCents, currency, expected }) => { + expect( + formatCurrencyFromCentsWithoutCurrencySymbol( + priceInCents, + currency, + DEFAULT_PACIFIC_FRANC_TO_EURO_RATE + ) + ).toBe(expected) + } + ) +}) diff --git a/src/libs/parsers/formatCurrencyFromCentsWithoutCurrencySymbol.ts b/src/libs/parsers/formatCurrencyFromCentsWithoutCurrencySymbol.ts new file mode 100644 index 00000000000..75fc201dcd1 --- /dev/null +++ b/src/libs/parsers/formatCurrencyFromCentsWithoutCurrencySymbol.ts @@ -0,0 +1,16 @@ +import { convertCentsToEuros } from 'libs/parsers/pricesConversion' +import { RoundUnit, convertEuroToPacificFranc } from 'shared/currency/convertEuroToPacificFranc' +import { Currency } from 'shared/currency/useGetCurrencyToDisplay' + +export const formatCurrencyFromCentsWithoutCurrencySymbol = ( + priceInCents: number, + currency: Currency, + euroToPacificFrancRate: number +): number => { + const priceInEuro = convertCentsToEuros(priceInCents) + + if (currency === Currency.PACIFIC_FRANC_SHORT || currency === Currency.PACIFIC_FRANC_FULL) { + return convertEuroToPacificFranc(priceInEuro, euroToPacificFrancRate, RoundUnit.UNITS) + } + return Math.floor(priceInEuro * 100) / 100 +}