diff --git a/src/features/filters/__tests__/Filters.test.tsx b/src/features/filters/__tests__/Filters.test.tsx index 4c9157d..9792cac 100644 --- a/src/features/filters/__tests__/Filters.test.tsx +++ b/src/features/filters/__tests__/Filters.test.tsx @@ -58,6 +58,7 @@ describe('Filters component', () => { { { { { { { { { { expect(onResetActiveFilters).toBeCalled() }) }) + +describe('Filters component with hasSubmitButton', () => { + it('should render "Filtra" and "Annulla filtri" buttons', () => { + const screen = renderWithRouter( + + ) + + expect(screen.getByRole('button', { name: 'Filtra' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Annulla filtri' })).toBeInTheDocument() + }) + + it('should not render submit buttons when hasSubmitButton is not set', () => { + const screen = renderWithRouter( + + ) + + expect(screen.queryByRole('button', { name: 'Filtra' })).not.toBeInTheDocument() + }) + + it('should not apply filters immediately when typing in a freetext field', async () => { + const user = userEvent.setup() + const onChangeActiveFilterFn = vi.fn() + + const screen = renderWithRouter( + + ) + + const singleFilterField = screen.getByLabelText('Single Filter Field') as HTMLInputElement + await user.type(singleFilterField, 'test-value') + expect(singleFilterField.value).toBe('test-value') + expect(onChangeActiveFilterFn).not.toHaveBeenCalled() + }) + + it('should not apply filters on Enter key in a freetext field', async () => { + const user = userEvent.setup() + const onChangeActiveFilterFn = vi.fn() + + const screen = renderWithRouter( + + ) + + const singleFilterField = screen.getByLabelText('Single Filter Field') as HTMLInputElement + await user.type(singleFilterField, 'test-value{enter}') + expect(onChangeActiveFilterFn).not.toHaveBeenCalled() + }) + + it('should not render search icon in freetext fields', () => { + const screen = renderWithRouter( + + ) + + expect(screen.queryByLabelText('Filtra')).not.toBeInTheDocument() + }) + + it('should call onSetActiveFilters with all field values when clicking "Filtra"', async () => { + const user = userEvent.setup() + const onSetActiveFiltersFn = vi.fn() + + const screen = renderWithRouter( + + ) + + const singleFilterField = screen.getByLabelText('Single Filter Field') as HTMLInputElement + await user.type(singleFilterField, 'test-value') + + const submitButton = screen.getByRole('button', { name: 'Filtra' }) + await user.click(submitButton) + + expect(onSetActiveFiltersFn).toHaveBeenCalledWith( + fieldMocks, + expect.objectContaining({ 'single-field': 'test-value' }) + ) + }) + + it('should reset fields and call onResetActiveFilters when clicking "Annulla filtri"', async () => { + const user = userEvent.setup() + const onResetActiveFiltersFn = vi.fn() + + const screen = renderWithRouter( + + ) + + const singleFilterField = screen.getByLabelText('Single Filter Field') as HTMLInputElement + await user.type(singleFilterField, 'something') + expect(singleFilterField.value).toBe('something') + + const cancelButton = screen.getByRole('button', { name: 'Annulla filtri' }) + await user.click(cancelButton) + + expect(onResetActiveFiltersFn).toHaveBeenCalled() + expect(singleFilterField.value).toBe('') + }) +}) diff --git a/src/features/filters/__tests__/FiltersIntegration.test.tsx b/src/features/filters/__tests__/FiltersIntegration.test.tsx new file mode 100644 index 0000000..a81793f --- /dev/null +++ b/src/features/filters/__tests__/FiltersIntegration.test.tsx @@ -0,0 +1,155 @@ +import React from 'react' +import { describe, expect, it } from 'vitest' +import userEvent from '@testing-library/user-event' +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useSearchParams } from 'react-router-dom' +import { Filters } from '../components/Filters' +import { useFilters } from '../hooks/useFilters' +import type { FilterFields, FiltersParams } from '../filters.types' + +const fieldMocks: FilterFields = [ + { name: 'q', type: 'freetext', label: 'Name' }, + { + name: 'status', + type: 'autocomplete-multiple', + options: [ + { label: 'Active', value: 'active' }, + { label: 'Suspended', value: 'suspended' }, + ], + label: 'Status', + }, +] + +/** + * Integration wrapper that wires useFilters + Filters together, + * matching the real usage pattern in the frontend. + * Includes a hidden element exposing current URL search params for assertions. + */ +const FiltersWrapper: React.FC<{ hasSubmitButton?: boolean }> = ({ hasSubmitButton }) => { + const { filtersParams: _filtersParams, ...filtersHandlers } = + useFilters(fieldMocks) + const [searchParams] = useSearchParams() + return ( + <> + +
{searchParams.toString()}
+ + ) +} + +function renderFiltersWrapper(hasSubmitButton = true, initialSearch?: string) { + const initialEntries = initialSearch ? [`/?${initialSearch}`] : ['/'] + return render( + + + } /> + + + ) +} + +function getSearchParams(): string { + return screen.getByTestId('search-params').textContent ?? '' +} + +describe('Filters integration with hasSubmitButton', () => { + it('should have "Filtra" and "Annulla filtri" buttons disabled when no filter value is entered', () => { + renderFiltersWrapper() + + expect(screen.getByRole('button', { name: 'Filtra' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Annulla filtri' })).toBeDisabled() + }) + + it('should enable buttons when a filter value is entered', async () => { + const user = userEvent.setup() + renderFiltersWrapper() + + await user.type(screen.getByLabelText('Name'), 'test') + + expect(screen.getByRole('button', { name: 'Filtra' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Annulla filtri' })).toBeEnabled() + }) + + it('should update URL params and show chip after clicking "Filtra"', async () => { + const user = userEvent.setup() + renderFiltersWrapper() + + await user.type(screen.getByLabelText('Name'), 'test-value') + await user.click(screen.getByRole('button', { name: 'Filtra' })) + + await waitFor(() => { + expect(getSearchParams()).toContain('q=test-value') + }) + + expect(screen.getByText('test-value')).toBeInTheDocument() + }) + + it('should remove chip and URL param when a chip is deleted', async () => { + const user = userEvent.setup() + renderFiltersWrapper(true, 'q=existing-filter') + + expect(screen.getByText('existing-filter')).toBeInTheDocument() + expect(getSearchParams()).toContain('q=existing-filter') + + await user.click(screen.getByTestId('CancelIcon')) + + await waitFor(() => { + expect(getSearchParams()).not.toContain('q=') + }) + + expect(screen.queryByText('existing-filter')).not.toBeInTheDocument() + }) + + it('should clear URL params when clicking "Annulla filtri" after submitting a filter', async () => { + const user = userEvent.setup() + renderFiltersWrapper() + + // Type and submit + await user.type(screen.getByLabelText('Name'), 'some-filter') + await user.click(screen.getByRole('button', { name: 'Filtra' })) + + await waitFor(() => { + expect(getSearchParams()).toContain('q=some-filter') + }) + expect(screen.getByText('some-filter')).toBeInTheDocument() + + // Type something new to re-enable the buttons, then cancel + await user.type(screen.getByLabelText('Name'), 'x') + await user.click(screen.getByRole('button', { name: 'Annulla filtri' })) + + await waitFor(() => { + expect(getSearchParams()).not.toContain('q=') + }) + + expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe('') + }) + + it('should delete offset from URL when submitting filters', async () => { + const user = userEvent.setup() + renderFiltersWrapper(true, 'offset=20') + + expect(getSearchParams()).toContain('offset=20') + + await user.type(screen.getByLabelText('Name'), 'test') + await user.click(screen.getByRole('button', { name: 'Filtra' })) + + await waitFor(() => { + expect(getSearchParams()).not.toContain('offset=') + }) + expect(getSearchParams()).toContain('q=test') + }) + + it('should delete offset from URL when removing a filter chip', async () => { + const user = userEvent.setup() + renderFiltersWrapper(true, 'q=test&offset=20') + + expect(getSearchParams()).toContain('offset=20') + + await user.click(screen.getByTestId('CancelIcon')) + + await waitFor(() => { + expect(getSearchParams()).not.toContain('offset=') + }) + expect(getSearchParams()).not.toContain('q=') + }) +}) diff --git a/src/features/filters/__tests__/__snapshots__/Filters.test.tsx.snap b/src/features/filters/__tests__/__snapshots__/Filters.test.tsx.snap index 294ef8a..9e67153 100644 --- a/src/features/filters/__tests__/__snapshots__/Filters.test.tsx.snap +++ b/src/features/filters/__tests__/__snapshots__/Filters.test.tsx.snap @@ -2,13 +2,13 @@ exports[`Filters component > matches the snapshot with more than one active filters 1`] = `
matches the snapshot with more than one active filt
matches the snapshot with more than one active filt
matches the snapshot with more than one active filt
matches the snapshot with more than one active filt

matches the snapshot with more than one active filt className="MuiStack-root css-1xvxq01-MuiStack-root" >
matches the snapshot with more than one active filt
matches the snapshot with more than one active filt
matches the snapshot with more than one active filt />
-
- -
+ Annulla filtri +
@@ -523,13 +519,13 @@ exports[`Filters component > matches the snapshot with more than one active filt exports[`Filters component > matches the snapshot with more than one active filters and right content 1`] = `
matches the snapshot with more than one active filt
matches the snapshot with more than one active filt
matches the snapshot with more than one active filt
matches the snapshot with more than one active filt

matches the snapshot with more than one active filt className="MuiStack-root css-1xvxq01-MuiStack-root" >
matches the snapshot with more than one active filt
matches the snapshot with more than one active filt
matches the snapshot with more than one active filt />
-
- -
+ Annulla filtri +
Right Content @@ -1047,13 +1039,13 @@ exports[`Filters component > matches the snapshot with more than one active filt exports[`Filters component > matches the snapshot with one active filter 1`] = `
matches the snapshot with one active filter 1`] = `
matches the snapshot with one active filter 1`] = `
matches the snapshot with one active filter 1`] = `
matches the snapshot with one active filter 1`] = `

matches the snapshot with one active filter 1`] = ` className="MuiStack-root css-1xvxq01-MuiStack-root" >
matches the snapshot with one active filter 1`] = ` exports[`Filters component > matches the snapshot with one active filter and a right content 1`] = `
matches the snapshot with one active filter and a r
matches the snapshot with one active filter and a r
matches the snapshot with one active filter and a r
matches the snapshot with one active filter and a r

matches the snapshot with one active filter and a r className="MuiStack-root css-1xvxq01-MuiStack-root" >
matches the snapshot with one active filter and a r exports[`Filters component > matches the snapshot without active filters 1`] = `
matches the snapshot without active filters 1`] = `
matches the snapshot without active filters 1`] = `
matches the snapshot without active filters 1`] = `
{ const result = getFiltersFieldsDefaultValue(fieldMocks) expect(result).toEqual({ + 'autocomplete-single-field': null, 'datepicker-field': null, 'single-field': '', 'multiple-field': [], diff --git a/src/features/filters/components/ActiveFiltersChips.tsx b/src/features/filters/components/ActiveFiltersChips.tsx index 5d0bd25..8cf2e2c 100644 --- a/src/features/filters/components/ActiveFiltersChips.tsx +++ b/src/features/filters/components/ActiveFiltersChips.tsx @@ -1,31 +1,43 @@ import React from 'react' import { Button, Chip, Divider, Stack } from '@mui/material' -import type { ActiveFilters, FiltersHandler } from '../filters.types' +import type { ActiveFilters, FilterHandler } from '../filters.types' import { getLocalizedValue } from '../../../utils/common.utils' +import { blue } from '@mui/material/colors' -type ActiveFilterChips = { +type ActiveFilterChipsProps = { activeFilters: ActiveFilters - onRemoveActiveFilter: FiltersHandler + onRemoveActiveFilter: FilterHandler onResetActiveFilters: VoidFunction + hasSubmitButton?: boolean rightContent?: React.ReactNode } -export const ActiveFilterChips: React.FC = ({ +const chipFocusStyles = { + '&.Mui-focusVisible': { + outline: `2px solid ${blue[400]}`, + outlineOffset: '1px', + }, +} + +const cancelFiltersLabel = getLocalizedValue({ + it: 'Annulla filtri', + en: 'Cancel filters', +}) + +export const ActiveFilterChips: React.FC = ({ activeFilters, onRemoveActiveFilter, onResetActiveFilters, + hasSubmitButton, rightContent, }) => { - if (activeFilters.length <= 0 && !rightContent) return null + if (activeFilters.length === 0 && !rightContent) return null - const cancelFiltersLabel = getLocalizedValue({ - it: 'Annulla filtri', - en: 'Cancel filters', - }) + const showResetButton = !hasSubmitButton && activeFilters.length > 1 return ( <> - + = ({ {activeFilters.map(({ value, label, type, filterKey }) => ( onRemoveActiveFilter(type, filterKey, value)} /> ))} - {activeFilters.length > 1 && ( - - - + {showResetButton && ( + )} {rightContent} diff --git a/src/features/filters/components/AutocompleteBaseFilterField.tsx b/src/features/filters/components/AutocompleteBaseFilterField.tsx index dfb6ce2..d1a56e3 100644 --- a/src/features/filters/components/AutocompleteBaseFilterField.tsx +++ b/src/features/filters/components/AutocompleteBaseFilterField.tsx @@ -14,7 +14,7 @@ type AutocompleteBaseFilterFieldProps = Omit< | 'size' | 'renderInput' | 'onInputChange' -> & { label: string; onInputChange?: (value: string) => void } +> & { label: string; onInputChange?: (value: string) => void; hasSubmitButton?: boolean } export const AutocompleteBaseFilterField = ( props: AutocompleteBaseFilterFieldProps @@ -24,6 +24,32 @@ export const AutocompleteBaseFilterField = ( en: 'No results', }) + function getSelectedElementsLabelText(): { label: string; ariaLabel: string } { + // If the filter has no submit button, or if it has a submit button but the value is empty, the label is just the field label + // otherwise it shows the number of selected elements + if (!props.hasSubmitButton || !Array.isArray(props.value) || props.value.length === 0) { + return { label: props.label, ariaLabel: props.label } + } + + const elementNumber = props.value.length + + const label = getLocalizedValue({ + it: `${props.label} (${elementNumber})`, + en: `${props.label} (${elementNumber})`, + }) + + const ariaLabel = getLocalizedValue({ + it: `${props.label} (${elementNumber} ${ + elementNumber === 1 ? 'elemento selezionato' : 'elementi selezionati' + })`, + en: `${props.label} (${elementNumber} ${ + elementNumber === 1 ? 'element' : 'elements' + } selected)`, + }) + + return { label, ariaLabel } + } + return ( {...props} @@ -46,7 +72,16 @@ export const AutocompleteBaseFilterField = ( props.onChange?.(event, data, reason) }} renderInput={(params) => { - return + return ( + + ) }} /> ) diff --git a/src/features/filters/components/AutocompleteMultipleFilterField.tsx b/src/features/filters/components/AutocompleteMultipleFilterField.tsx index 2eb79ad..1d48599 100644 --- a/src/features/filters/components/AutocompleteMultipleFilterField.tsx +++ b/src/features/filters/components/AutocompleteMultipleFilterField.tsx @@ -13,6 +13,7 @@ export const AutocompleteMultipleFilterField: React.FC = value, onChangeActiveFilter, onFieldsValuesChange, + hasSubmitButton, }) => { const field = _field as AutocompleteFilterFieldOptions const filterKey = field.name @@ -21,11 +22,13 @@ export const AutocompleteMultipleFilterField: React.FC = const handleAutocompleteMultipleChange = (data: FilterFieldsValues['string']) => { onFieldsValuesChange(filterKey, data) - clearTimeout(debounceRef.current) - debounceRef.current = setTimeout( - () => onChangeActiveFilter('autocomplete-multiple', filterKey, data), - 300 - ) + if (!hasSubmitButton) { + clearTimeout(debounceRef.current) + debounceRef.current = setTimeout( + () => onChangeActiveFilter('autocomplete-multiple', filterKey, data), + 300 + ) + } } return ( @@ -50,6 +53,7 @@ export const AutocompleteMultipleFilterField: React.FC = ) }} + hasSubmitButton={hasSubmitButton} /> ) } diff --git a/src/features/filters/components/AutocompleteSingleFilterField.tsx b/src/features/filters/components/AutocompleteSingleFilterField.tsx index ade42d4..2f7ee92 100644 --- a/src/features/filters/components/AutocompleteSingleFilterField.tsx +++ b/src/features/filters/components/AutocompleteSingleFilterField.tsx @@ -9,20 +9,24 @@ import { AutocompleteBaseFilterField } from './AutocompleteBaseFilterField' export const AutocompleteSingleFilterField: React.FC = ({ field: _field, + value, onChangeActiveFilter, + onFieldsValuesChange, + hasSubmitButton, }) => { const field = _field as AutocompleteFilterFieldOptions const filterKey = field.name const handleAutocompleteSingleChange = (data: FilterFieldsValues['string']) => { - onChangeActiveFilter('autocomplete-single', filterKey, data) + onFieldsValuesChange(filterKey, data) + if (!hasSubmitButton) onChangeActiveFilter('autocomplete-single', filterKey, data) } return ( label={field.label} blurOnSelect - value={null as unknown as FilterOption} + value={value as FilterOption} options={field.options} onInputChange={field?.onTextInputChange} onChange={(_, data) => { diff --git a/src/features/filters/components/DatepickerFilterField.tsx b/src/features/filters/components/DatepickerFilterField.tsx index 3f45ad2..6428564 100644 --- a/src/features/filters/components/DatepickerFilterField.tsx +++ b/src/features/filters/components/DatepickerFilterField.tsx @@ -15,6 +15,7 @@ export const DatepickerFilterField: React.FC = ({ value, onChangeActiveFilter, onFieldsValuesChange, + hasSubmitButton, }) => { const field = _field as DatepickerFilterFieldOptions const filterKey = field.name @@ -54,18 +55,23 @@ export const DatepickerFilterField: React.FC = ({ inputAdornment: (inputAdornmentProps) => ( <> - - - + {!hasSubmitButton && ( + + + + )} ), }} slotProps={{ - textField: { size: 'small', onKeyDown: handleDatepickerKeyDown }, + textField: { + size: 'small', + onKeyDown: hasSubmitButton ? undefined : handleDatepickerKeyDown, + }, }} /> diff --git a/src/features/filters/components/Filters.tsx b/src/features/filters/components/Filters.tsx index 795076d..5ad4cf4 100644 --- a/src/features/filters/components/Filters.tsx +++ b/src/features/filters/components/Filters.tsx @@ -4,7 +4,7 @@ import { ActiveFilterChips } from './ActiveFiltersChips' import { FiltersFields } from './FiltersFields' import type { FilterOption, - FiltersHandler, + FilterHandler, FiltersHandlers, FilterFieldValue, FilterFieldsValues, @@ -12,7 +12,10 @@ import type { import { getFiltersFieldsInitialValues, getFiltersFieldsDefaultValue } from '../filters.utils' import { useSearchParams } from 'react-router-dom' -export type FiltersProps = FiltersHandlers & { rightContent?: React.ReactNode } +export type FiltersProps = FiltersHandlers & { + hasSubmitButton?: boolean + rightContent?: React.ReactNode +} /** * Takes the filters handlers returned from the useFilters hook and renders the filters fields and the active filters chips. @@ -22,25 +25,30 @@ export const Filters: React.FC = ({ onChangeActiveFilter, onRemoveActiveFilter, onResetActiveFilters, + onSetActiveFilters, fields, + hasSubmitButton, rightContent, }) => { const [searchParams] = useSearchParams() const [fieldsValues, setFieldsValues] = React.useState(() => - getFiltersFieldsInitialValues(searchParams, fields) + getFiltersFieldsInitialValues(searchParams, fields, hasSubmitButton) ) const handleFieldsValuesChange = React.useCallback((name: string, value: FilterFieldValue) => { setFieldsValues((prev) => ({ ...prev, [name]: value })) }, []) - const handleRemoveActiveFilter: FiltersHandler = (type, filterKey, value) => { + const handleRemoveActiveFilter: FilterHandler = (type, filterKey, value) => { if (type === 'autocomplete-multiple') { const fieldValue = fieldsValues[filterKey] as Array handleFieldsValuesChange( filterKey, fieldValue.filter(({ value: v }) => v !== value) ) + } else { + const defaultValues = getFiltersFieldsDefaultValue(fields) + handleFieldsValuesChange(filterKey, defaultValues[filterKey]) } onRemoveActiveFilter(type, filterKey, value) } @@ -50,18 +58,26 @@ export const Filters: React.FC = ({ onResetActiveFilters() } + const handleSubmit = () => { + onSetActiveFilters(fields, fieldsValues) + } + return ( - + diff --git a/src/features/filters/components/FiltersFields.tsx b/src/features/filters/components/FiltersFields.tsx index 80a232a..4e7e555 100644 --- a/src/features/filters/components/FiltersFields.tsx +++ b/src/features/filters/components/FiltersFields.tsx @@ -1,22 +1,31 @@ import React from 'react' -import { Grid } from '@mui/material' +import { Button, Grid } from '@mui/material' import type { FilterFieldValue, FilterFieldsValues, FilterFields, - FiltersHandler, + FilterHandler, } from '../filters.types' import { AutocompleteMultipleFilterField } from './AutocompleteMultipleFilterField' import { AutocompleteSingleFilterField } from './AutocompleteSingleFilterField' import { DatepickerFilterField } from './DatepickerFilterField' import { NumericFilterField } from './NumericFilterField' import { FreetextFilterField } from './FreetextFilterField' +import { getLocalizedValue } from '@/utils/common.utils' + +/** + * The default width of the filter field in a 12-column grid system. + */ +const filterFieldDefaultWidth = 3 type FiltersFieldsProps = { fields: FilterFields - onChangeActiveFilter: FiltersHandler + onChangeActiveFilter: FilterHandler fieldsValues: FilterFieldsValues onFieldsValuesChange: (name: string, value: FilterFieldValue) => void + onResetActiveFilters: VoidFunction + onSubmit: VoidFunction + hasSubmitButton?: boolean } export const FiltersFields: React.FC = ({ @@ -24,7 +33,24 @@ export const FiltersFields: React.FC = ({ fieldsValues, onFieldsValuesChange, onChangeActiveFilter, + onResetActiveFilters, + onSubmit, + hasSubmitButton, }) => { + const cancelFiltersLabel = getLocalizedValue({ + it: 'Annulla filtri', + en: 'Cancel filters', + }) + + const submitFiltersLabel = getLocalizedValue({ + it: 'Filtra', + en: 'Filter', + }) + + const buttonDisabled = Object.entries(fieldsValues).every( + ([_, value]) => value === null || value === '' || (Array.isArray(value) && value.length === 0) + ) + return ( {fields.map((field) => { @@ -33,9 +59,10 @@ export const FiltersFields: React.FC = ({ value: fieldsValues[field.name], onChangeActiveFilter, onFieldsValuesChange, + hasSubmitButton, } return ( - + {field.type === 'freetext' && } {field.type === 'numeric' && } {field.type === 'autocomplete-multiple' && ( @@ -48,6 +75,29 @@ export const FiltersFields: React.FC = ({ ) })} + {hasSubmitButton && ( + + + + + )} ) } diff --git a/src/features/filters/components/FreetextFilterField.tsx b/src/features/filters/components/FreetextFilterField.tsx index dfb57ec..5a8789a 100644 --- a/src/features/filters/components/FreetextFilterField.tsx +++ b/src/features/filters/components/FreetextFilterField.tsx @@ -9,6 +9,7 @@ export const FreetextFilterField: React.FC = ({ value, onChangeActiveFilter, onFieldsValuesChange, + hasSubmitButton, }) => { const searchIconAriaLabel = getLocalizedValue({ it: 'Filtra', en: 'Filter' }) const filterKey = field.name @@ -40,9 +41,9 @@ export const FreetextFilterField: React.FC = ({ name={field.name} value={value} onChange={handleFieldValueChange} - onKeyDown={handleKeyDown} + onKeyDown={hasSubmitButton ? undefined : handleKeyDown} InputProps={{ - endAdornment: ( + endAdornment: hasSubmitButton ? undefined : ( diff --git a/src/features/filters/components/NumericFilterField.tsx b/src/features/filters/components/NumericFilterField.tsx index 1b7dc14..a54a186 100644 --- a/src/features/filters/components/NumericFilterField.tsx +++ b/src/features/filters/components/NumericFilterField.tsx @@ -9,6 +9,7 @@ export const NumericFilterField: React.FC = ({ value, onChangeActiveFilter, onFieldsValuesChange, + hasSubmitButton, }) => { const field = _field as NumericFilterFieldOptions const searchIconAriaLabel = getLocalizedValue({ it: 'Filtra', en: 'Filter' }) @@ -61,7 +62,7 @@ export const NumericFilterField: React.FC = ({ name={field.name} value={value} onChange={handleTextFieldChange} - onKeyDown={handleKeyDown} + onKeyDown={hasSubmitButton ? undefined : handleKeyDown} onBlur={handleBlur} InputProps={{ inputProps: { @@ -69,7 +70,7 @@ export const NumericFilterField: React.FC = ({ max: field.max, style: { paddingRight: 20 }, }, - endAdornment: ( + endAdornment: hasSubmitButton ? undefined : ( diff --git a/src/features/filters/filters.types.ts b/src/features/filters/filters.types.ts index b13e0be..1f34f84 100644 --- a/src/features/filters/filters.types.ts +++ b/src/features/filters/filters.types.ts @@ -12,9 +12,10 @@ export type FiltersParams = Record export type FiltersHandlers = { fields: FilterFields activeFilters: ActiveFilters - onChangeActiveFilter: FiltersHandler - onRemoveActiveFilter: FiltersHandler + onChangeActiveFilter: FilterHandler + onRemoveActiveFilter: FilterHandler onResetActiveFilters: VoidFunction + onSetActiveFilters: FiltersHandler } type FilterFieldCommon = { @@ -27,6 +28,10 @@ type FilterFieldCommon = { * The label of the filter field. */ label: string + /** + * The width of the filter field in a 12-column grid system. + */ + width?: number } export type FreetextFilterFieldOptions = FilterFieldCommon & { @@ -82,16 +87,19 @@ export type FilterField = export type FilterFieldCommonProps = { field: FilterField value: FilterFieldValue - onChangeActiveFilter: FiltersHandler + onChangeActiveFilter: FilterHandler onFieldsValuesChange: (name: string, value: FilterFieldValue) => void + hasSubmitButton?: boolean } export type FilterFields = FilterField[] export type FilterOption = { label: string; value: string } -export type FiltersHandler = ( +export type FilterHandler = ( type: FilterFieldType, filterKey: string, value: FilterFieldValue ) => void + +export type FiltersHandler = (fields: FilterFields, fieldsValues: FilterFieldsValues) => void diff --git a/src/features/filters/filters.utils.ts b/src/features/filters/filters.utils.ts index 855c930..e18b5dc 100644 --- a/src/features/filters/filters.utils.ts +++ b/src/features/filters/filters.utils.ts @@ -9,18 +9,19 @@ import type { /** Map passed fields options to the field state default value */ export function getFiltersFieldsDefaultValue(fields: FilterFields): FilterFieldsValues { - return fields.reduce((prev, field) => { - if (field.type === 'autocomplete-multiple') { - return { ...prev, [field.name]: [] } - } - if (field.type === 'autocomplete-single') { - // autocomplete single has no need to be in the fields state - return prev - } - if (field.type === 'datepicker') { - return { ...prev, [field.name]: null } + return fields.reduce((acc, field) => { + switch (field.type) { + case 'autocomplete-multiple': + acc[field.name] = [] + break + case 'autocomplete-single': + case 'datepicker': + acc[field.name] = null + break + default: + acc[field.name] = '' } - return { ...prev, [field.name]: '' } + return acc }, {}) } @@ -32,7 +33,8 @@ export function getFiltersFieldsDefaultValue(fields: FilterFields): FilterFields */ export const getFiltersFieldsInitialValues = ( searchParams: URLSearchParams, - filtersFields: FilterFields + filtersFields: FilterFields, + hasSubmitButton?: boolean ) => { const fieldsValues: FilterFieldsValues = {} filtersFields.forEach((field) => { @@ -46,7 +48,15 @@ export const getFiltersFieldsInitialValues = ( fieldsValues[field.name] = [] break case 'autocomplete-single': - // autocomplete single has no need to be in the fields state + // autocomplete single has no need to be in the fields state when no submit button + if (hasSubmitButton) { + const singleFilterParamValue = searchParams.get(field.name) + if (singleFilterParamValue) { + fieldsValues[field.name] = decodeSingleFilterFieldValue(singleFilterParamValue) + return + } + fieldsValues[field.name] = null + } break case 'freetext': case 'numeric': diff --git a/src/features/filters/hooks/useFilters.ts b/src/features/filters/hooks/useFilters.ts index 5bdee0f..e24eeb1 100644 --- a/src/features/filters/hooks/useFilters.ts +++ b/src/features/filters/hooks/useFilters.ts @@ -2,9 +2,11 @@ import React from 'react' import type { FilterFields, FilterOption, - FiltersHandler, + FilterHandler, FiltersHandlers, FiltersParams, + FiltersHandler, + FilterFieldType, } from '../filters.types' import { useSearchParams } from 'react-router-dom' import { @@ -14,6 +16,50 @@ import { encodeSingleFilterFieldValue, } from '../filters.utils' +type FilterValue = Parameters[2] + +function shouldRemoveFilter(type: FilterFieldType, value: FilterValue): boolean { + if ((type === 'datepicker' || type === 'autocomplete-single') && value === null) { + return true + } + if ( + ['freetext', 'autocomplete-multiple', 'numeric'].includes(type) && + (value as string | FilterOption[]).length === 0 + ) { + return true + } + return false +} + +function setFilterParam( + searchParams: URLSearchParams, + type: FilterFieldType, + filterKey: string, + value: FilterValue +): void { + switch (type) { + case 'numeric': + case 'freetext': { + searchParams.set(filterKey, String(value)) + break + } + case 'autocomplete-multiple': { + const encoded = encodeMultipleFilterFieldValue(value as FilterOption[]) + searchParams.set(filterKey, encoded) + break + } + case 'autocomplete-single': { + const encoded = encodeSingleFilterFieldValue(value as FilterOption) + searchParams.set(filterKey, encoded) + break + } + case 'datepicker': { + searchParams.set(filterKey, (value as Date).toISOString()) + break + } + } +} + /** * @description * This hook is used to manage the filters state keeping it in sync with the url params. @@ -67,43 +113,35 @@ export function useFilters( ): FiltersHandlers & { filtersParams: TFiltersParams } { const [searchParams, setSearchParams] = useSearchParams() - const onChangeActiveFilter = React.useCallback( + const onChangeActiveFilter = React.useCallback( (type, filterKey, value) => { setSearchParams((searchParams) => { - let shouldBeRemoved = false - if (type === 'datepicker' && value === null) { - shouldBeRemoved = true - } - if ( - ['freetext', 'autocomplete-multiple', 'numeric'].includes(type) && - (value as string | Array).length === 0 - ) { - shouldBeRemoved = true - } - if (shouldBeRemoved) { + if (shouldRemoveFilter(type, value)) { searchParams.delete(filterKey) - return searchParams + } else { + setFilterParam(searchParams, type, filterKey, value) + searchParams.delete('offset') } + return searchParams + }) + }, + [setSearchParams] + ) + + const onSetActiveFilters = React.useCallback( + (fields, fieldsValues) => { + setSearchParams((searchParams) => { + fields.forEach((field) => { + const { type, name: filterKey } = field + const value = fieldsValues[filterKey] + + if (shouldRemoveFilter(type, value)) { + searchParams.delete(filterKey) + } else { + setFilterParam(searchParams, type, filterKey, value) + } + }) - switch (type) { - case 'numeric': - case 'freetext': - searchParams.set(filterKey, String(value)) - break - case 'autocomplete-multiple': - const urlParamMultipleFilterValue = encodeMultipleFilterFieldValue( - value as Array - ) - searchParams.set(filterKey, urlParamMultipleFilterValue) - break - case 'autocomplete-single': - const urlParamSingleFilterValue = encodeSingleFilterFieldValue(value as FilterOption) - searchParams.set(filterKey, urlParamSingleFilterValue) - break - case 'datepicker': - searchParams.set(filterKey, (value as Date).toISOString()) - break - } searchParams.delete('offset') return searchParams }) @@ -111,7 +149,7 @@ export function useFilters( [setSearchParams] ) - const onRemoveActiveFilter = React.useCallback( + const onRemoveActiveFilter = React.useCallback( (type, filterKey, value) => { setSearchParams((searchParams) => { switch (type) { @@ -167,5 +205,6 @@ export function useFilters( onResetActiveFilters, onChangeActiveFilter, onRemoveActiveFilter, + onSetActiveFilters, } } diff --git a/src/features/filters/stories/FiltersExample.tsx b/src/features/filters/stories/FiltersExample.tsx index 869819d..ce25416 100644 --- a/src/features/filters/stories/FiltersExample.tsx +++ b/src/features/filters/stories/FiltersExample.tsx @@ -20,18 +20,18 @@ const _FiltersExample: React.FC = () => { const location = useLocation() const { filtersParams, ...handlers } = useFilters([ - { name: 'q', type: 'freetext', label: 'Find by name' }, + { name: 'q', type: 'freetext', label: 'Nome' }, { name: 'version', type: 'numeric', - label: 'Find by version number', + label: 'Cerca per versione', min: 10, max: 20, }, { name: 'consumerId', type: 'autocomplete-multiple', - label: 'Find by consumer', + label: 'Cerca per consumatore', options: [ { value: 'option-1', label: 'PagoPA S.p.A.' }, { value: 'option-2', label: 'Agenzia delle Entrate' }, @@ -41,17 +41,17 @@ const _FiltersExample: React.FC = () => { { name: 'state', type: 'autocomplete-single', - label: 'Find by State', + label: 'Stato', options: [ - { value: 'option-1', label: 'PagoPA S.p.A.' }, - { value: 'option-2', label: 'Agenzia delle Entrate' }, + { value: 'pagoPA', label: 'PagoPA S.p.A.' }, + { value: 'agenziaEntrate', label: 'Agenzia delle Entrate' }, ], onTextInputChange: setAutocompleteStateTextInput, }, { name: 'createdAt', type: 'datepicker', - label: 'Find by creation date', + label: 'Data di creazione', minDate: new Date(), // Today plus 1 year maxDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)), @@ -70,7 +70,7 @@ const _FiltersExample: React.FC = () => { return ( <> - +