@mistertemp/libs-front-shared 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +18 -0
- package/index.ts +3 -0
- package/package.json +100 -0
- package/src/components/ControlledInputDate/ControlledInputDate.tsx +82 -0
- package/src/components/ControlledInputDate/__tests__/ControlledInputDate.unit.test.tsx +78 -0
- package/src/components/ControlledInputDate/index.ts +1 -0
- package/src/components/ControlledInputTime/ControlledInputTime.tsx +70 -0
- package/src/components/ControlledInputTime/__tests__/ControlledInputTime.unit.test.tsx +108 -0
- package/src/components/ControlledInputTime/index.ts +1 -0
- package/src/components/CopyToClipboard/CopyToClipboard.tsx +61 -0
- package/src/components/CopyToClipboard/__tests__/CopyToClipboard.unit.test.tsx +54 -0
- package/src/components/CopyToClipboard/index.ts +1 -0
- package/src/components/DateFilter/DateFilter.module.scss +4 -0
- package/src/components/DateFilter/DateFilter.tsx +117 -0
- package/src/components/DateFilter/DateFilter.utils.tsx +65 -0
- package/src/components/DateFilter/__tests__/DateFilter.unit.test.tsx +39 -0
- package/src/components/DateFilter/index.ts +1 -0
- package/src/components/EnvironmentFilter/EnvironmentFilter.tsx +74 -0
- package/src/components/EnvironmentFilter/__tests__/EnvironmentFilter.unit.test.tsx +99 -0
- package/src/components/EnvironmentFilter/index.ts +1 -0
- package/src/components/Filter/Filter.tsx +127 -0
- package/src/components/Filter/index.ts +1 -0
- package/src/components/ProfessionFilter/ProfessionFilter.module.scss +4 -0
- package/src/components/ProfessionFilter/ProfessionFilter.tsx +145 -0
- package/src/components/ProfessionFilter/__tests__/ProfessionFilter.unit.test.tsx +195 -0
- package/src/components/ProfessionFilter/index.ts +1 -0
- package/src/components/TextFilter/TextFilter.module.scss +3 -0
- package/src/components/TextFilter/TextFilter.tsx +112 -0
- package/src/components/TextFilter/__tests__/TextFilter.unit.test.tsx +75 -0
- package/src/components/TextFilter/index.ts +1 -0
- package/src/components/index.ts +8 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useBundledTranslation.ts +35 -0
- package/src/hooks/useDebouncedSearchInput.ts +36 -0
- package/src/hooks/usePrevious.ts +13 -0
- package/src/locales/fr/date-picker.json +11 -0
- package/src/locales/fr/scc-common-filters.json +22 -0
- package/src/locales/it/date-picker.json +11 -0
- package/src/locales/it/scc-common-filters.json +22 -0
- package/src/mocks/professions.ts +24 -0
- package/src/typings/filters.ts +7 -0
- package/src/typings/index.ts +1 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DatePicker,
|
|
3
|
+
Dropdown,
|
|
4
|
+
DropdownProps,
|
|
5
|
+
DropdownSelectorTrigger,
|
|
6
|
+
DropdownSelectorTriggerProps,
|
|
7
|
+
} from '@mistertemp/design-system';
|
|
8
|
+
import {
|
|
9
|
+
DatePickerProps,
|
|
10
|
+
MixedDatePickerSelection,
|
|
11
|
+
} from '@mistertemp/design-system/src/components/design-system/DatePicker/DatePicker';
|
|
12
|
+
import { Locale } from 'date-fns';
|
|
13
|
+
import { fr } from 'date-fns/locale';
|
|
14
|
+
import { TFunction } from 'i18next';
|
|
15
|
+
import React, { useEffect, useRef, useState, VFC } from 'react';
|
|
16
|
+
|
|
17
|
+
import { useBundledTranslation } from '../../hooks';
|
|
18
|
+
import frDate from '../../locales/fr/date-picker.json';
|
|
19
|
+
import frFilters from '../../locales/fr/scc-common-filters.json';
|
|
20
|
+
import itDate from '../../locales/it/date-picker.json';
|
|
21
|
+
import itFilters from '../../locales/it/scc-common-filters.json';
|
|
22
|
+
import styles from './DateFilter.module.scss';
|
|
23
|
+
import { deduceMode, defaultDateFormat, getDisplayValue, sanitizeValue } from './DateFilter.utils';
|
|
24
|
+
|
|
25
|
+
export type DateFilterProps = Omit<
|
|
26
|
+
DropdownProps,
|
|
27
|
+
'trigger' | 'onOpenChange' | 'dropdownValue' | 'onDropdownValueChanged' | 'withCTA' | 'ctaTitle'
|
|
28
|
+
> & {
|
|
29
|
+
name?: string;
|
|
30
|
+
format?: string;
|
|
31
|
+
locale?: Locale;
|
|
32
|
+
selectorProps?: Omit<DropdownSelectorTriggerProps, 'isSelected' | 'title'>;
|
|
33
|
+
dropdownValue?: MixedDatePickerSelection;
|
|
34
|
+
onDropdownValueChanged?: (value: MixedDatePickerSelection) => void;
|
|
35
|
+
modes?: DatePickerProps['availableModes'];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const DateFilter: VFC<DateFilterProps> = ({
|
|
39
|
+
name = 'date',
|
|
40
|
+
'data-testid': dataTestId = `filters+${name}`,
|
|
41
|
+
format = defaultDateFormat,
|
|
42
|
+
locale = fr,
|
|
43
|
+
dropdownValue,
|
|
44
|
+
onDropdownValueChanged,
|
|
45
|
+
modes,
|
|
46
|
+
selectorProps,
|
|
47
|
+
...props
|
|
48
|
+
}) => {
|
|
49
|
+
const { t: tFilters } = useBundledTranslation('scc-common-filters', {
|
|
50
|
+
fr: frFilters,
|
|
51
|
+
it: itFilters,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const { t: tDate } = useBundledTranslation('date-picker', { fr: frDate, it: itDate });
|
|
55
|
+
|
|
56
|
+
const [open, setOpen] = useState(false);
|
|
57
|
+
const selectedValue = useRef(sanitizeValue(dropdownValue));
|
|
58
|
+
|
|
59
|
+
const mode = deduceMode(selectedValue.current, modes);
|
|
60
|
+
|
|
61
|
+
const handleOpenChange = (isOpen: boolean) => {
|
|
62
|
+
if (!isOpen) {
|
|
63
|
+
onDropdownValueChanged?.(selectedValue.current);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setOpen(isOpen);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
selectedValue.current = sanitizeValue(dropdownValue);
|
|
71
|
+
}, [dropdownValue]);
|
|
72
|
+
|
|
73
|
+
const isSelected = !!selectedValue.current;
|
|
74
|
+
const displayValue = getDisplayValue(selectedValue.current, format);
|
|
75
|
+
const placeholder = tFilters('scc-common-filters:filters.date.placeholder');
|
|
76
|
+
const triggerTitle = displayValue ? `${placeholder} : ${displayValue}` : placeholder;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Dropdown
|
|
80
|
+
isOpen={open}
|
|
81
|
+
onOpenChange={handleOpenChange}
|
|
82
|
+
dropdownClassName={styles.dropdown}
|
|
83
|
+
dropdownValue={{
|
|
84
|
+
[name]: selectedValue.current,
|
|
85
|
+
}}
|
|
86
|
+
onDropdownCTAClick={() => {}}
|
|
87
|
+
trigger={
|
|
88
|
+
<DropdownSelectorTrigger
|
|
89
|
+
data-testid={`${dataTestId}+trigger`}
|
|
90
|
+
title={triggerTitle}
|
|
91
|
+
isSelected={isSelected}
|
|
92
|
+
type="primary"
|
|
93
|
+
disabled={props.disabled}
|
|
94
|
+
{...selectorProps}
|
|
95
|
+
/>
|
|
96
|
+
}>
|
|
97
|
+
<DatePicker
|
|
98
|
+
t={tDate as TFunction}
|
|
99
|
+
locale={locale}
|
|
100
|
+
data-testid={`${dataTestId}+picker`}
|
|
101
|
+
availableModes={modes}
|
|
102
|
+
showModeSwitch={!!modes?.length}
|
|
103
|
+
mode={mode}
|
|
104
|
+
selected={selectedValue.current}
|
|
105
|
+
onSubmit={(value) => {
|
|
106
|
+
onDropdownValueChanged?.(value);
|
|
107
|
+
setOpen(false);
|
|
108
|
+
}}
|
|
109
|
+
onSelect={(selected) => (selectedValue.current = selected)}
|
|
110
|
+
onCancel={() => {
|
|
111
|
+
selectedValue.current = sanitizeValue(dropdownValue);
|
|
112
|
+
setOpen(false);
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
</Dropdown>
|
|
116
|
+
);
|
|
117
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DatePickerMode,
|
|
3
|
+
DatePickerProps,
|
|
4
|
+
MixedDatePickerSelection,
|
|
5
|
+
} from '@mistertemp/design-system/src/components/design-system/DatePicker/DatePicker';
|
|
6
|
+
import { format, isDate as isDateFn, min } from 'date-fns';
|
|
7
|
+
import { DateRange } from 'react-day-picker';
|
|
8
|
+
|
|
9
|
+
export const defaultDateFormat = 'dd/MM/yy';
|
|
10
|
+
|
|
11
|
+
const isMultiple = (selection: MixedDatePickerSelection): selection is Date[] =>
|
|
12
|
+
Array.isArray(selection);
|
|
13
|
+
|
|
14
|
+
const isRange = (selection: MixedDatePickerSelection): selection is DateRange =>
|
|
15
|
+
typeof selection === 'object' && 'from' in selection;
|
|
16
|
+
|
|
17
|
+
const isDate = (selection: MixedDatePickerSelection): selection is Date => isDateFn(selection);
|
|
18
|
+
|
|
19
|
+
export const deduceMode = (
|
|
20
|
+
selection: MixedDatePickerSelection,
|
|
21
|
+
availableModes: DatePickerProps['availableModes'],
|
|
22
|
+
): DatePickerMode => {
|
|
23
|
+
switch (true) {
|
|
24
|
+
case isMultiple(selection):
|
|
25
|
+
return 'multiple';
|
|
26
|
+
case isRange(selection):
|
|
27
|
+
return 'range';
|
|
28
|
+
case isDate(selection):
|
|
29
|
+
return 'single';
|
|
30
|
+
default:
|
|
31
|
+
return availableModes?.length ? availableModes[0] : 'single';
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const sanitizeValue = (
|
|
36
|
+
value: MixedDatePickerSelection | object,
|
|
37
|
+
): MixedDatePickerSelection => {
|
|
38
|
+
if (
|
|
39
|
+
!isDate(value as MixedDatePickerSelection) &&
|
|
40
|
+
typeof value === 'object' &&
|
|
41
|
+
!Object.keys(value).length
|
|
42
|
+
) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return value as MixedDatePickerSelection;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const getDisplayValue = (
|
|
50
|
+
selection: MixedDatePickerSelection,
|
|
51
|
+
dateFormat: string,
|
|
52
|
+
): string | undefined => {
|
|
53
|
+
switch (true) {
|
|
54
|
+
case isMultiple(selection) && !!selection.length:
|
|
55
|
+
return selection.length > 1
|
|
56
|
+
? `${format(min(selection), dateFormat)} (+${selection.length - 1})`
|
|
57
|
+
: format(selection[0], dateFormat);
|
|
58
|
+
case isRange(selection) && !!selection.from:
|
|
59
|
+
return `${format(selection.from, dateFormat)} - ${selection.to ? format(selection.to, dateFormat) : ''}`;
|
|
60
|
+
case isDate(selection):
|
|
61
|
+
return format(selection, dateFormat);
|
|
62
|
+
default:
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { screen } from '@testing-library/react';
|
|
2
|
+
import { format, startOfToday, startOfTomorrow } from 'date-fns';
|
|
3
|
+
|
|
4
|
+
import { render } from '../../../../.jest/reactTestingLibrary.config';
|
|
5
|
+
import { DateFilter } from '../DateFilter';
|
|
6
|
+
|
|
7
|
+
describe('DateFilter', () => {
|
|
8
|
+
it('should display multiple values', () => {
|
|
9
|
+
const today = startOfToday();
|
|
10
|
+
const tomorrow = startOfTomorrow();
|
|
11
|
+
|
|
12
|
+
render(<DateFilter dropdownValue={[today, tomorrow]} />);
|
|
13
|
+
|
|
14
|
+
expect(screen.getByTestId('filters+date+trigger-button')).toHaveTextContent(
|
|
15
|
+
`${format(today, 'dd/MM/yy')} (+1)`,
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should display range values', () => {
|
|
20
|
+
const today = startOfToday();
|
|
21
|
+
const tomorrow = startOfTomorrow();
|
|
22
|
+
|
|
23
|
+
render(<DateFilter dropdownValue={{ from: today, to: tomorrow }} />);
|
|
24
|
+
|
|
25
|
+
expect(screen.getByTestId('filters+date+trigger-button')).toHaveTextContent(
|
|
26
|
+
`${format(today, 'dd/MM/yy')} - ${format(tomorrow, 'dd/MM/yy')}`,
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should display single values', () => {
|
|
31
|
+
const today = startOfToday();
|
|
32
|
+
|
|
33
|
+
render(<DateFilter dropdownValue={today} />);
|
|
34
|
+
|
|
35
|
+
expect(screen.getByTestId('filters+date+trigger-button')).toHaveTextContent(
|
|
36
|
+
format(today, 'dd/MM/yy'),
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './DateFilter';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ENVIRONMENTS_CODE, getAllFlattenedEnvironments } from '@mistertemp/libs-work-environments';
|
|
2
|
+
import Fuse, { IFuseOptions } from 'fuse.js';
|
|
3
|
+
import { merge } from 'lodash';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { useMemo, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
import { useBundledTranslation } from '../../hooks';
|
|
8
|
+
import fr from '../../locales/fr/scc-common-filters.json';
|
|
9
|
+
import it from '../../locales/it/scc-common-filters.json';
|
|
10
|
+
import { Filter, FilterDropdownOption, FilterProps } from '../Filter';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_FUSE_OPTIONS: IFuseOptions<FilterDropdownOption<ENVIRONMENTS_CODE>> = {
|
|
13
|
+
isCaseSensitive: false,
|
|
14
|
+
shouldSort: true,
|
|
15
|
+
findAllMatches: true,
|
|
16
|
+
ignoreLocation: true,
|
|
17
|
+
keys: ['label'],
|
|
18
|
+
threshold: 0.2,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type EnvironmentFilterProps = Omit<FilterProps<ENVIRONMENTS_CODE>, 'name' | 'options'> & {
|
|
22
|
+
name?: string;
|
|
23
|
+
fuseOptions?: IFuseOptions<FilterDropdownOption<ENVIRONMENTS_CODE>>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const options = getAllFlattenedEnvironments().map((env) => ({
|
|
27
|
+
id: env.code,
|
|
28
|
+
label: env.label,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
export const EnvironmentFilter = ({
|
|
32
|
+
listProps,
|
|
33
|
+
name = 'environment',
|
|
34
|
+
mode = 'multiple',
|
|
35
|
+
fuseOptions,
|
|
36
|
+
...props
|
|
37
|
+
}: EnvironmentFilterProps) => {
|
|
38
|
+
const { t } = useBundledTranslation('scc-common-filters', { fr, it });
|
|
39
|
+
const [inputValue, setInputValue] = useState<string | undefined>(undefined);
|
|
40
|
+
|
|
41
|
+
const fuse = useMemo(() => {
|
|
42
|
+
const fuse = new Fuse(options, { ...DEFAULT_FUSE_OPTIONS, ...fuseOptions });
|
|
43
|
+
|
|
44
|
+
return fuse;
|
|
45
|
+
}, [fuseOptions]);
|
|
46
|
+
|
|
47
|
+
const filteredOptions = useMemo(() => {
|
|
48
|
+
if (inputValue) {
|
|
49
|
+
return fuse.search(inputValue).map((r) => r.item);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return options;
|
|
53
|
+
}, [fuse, inputValue]);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Filter
|
|
57
|
+
name={name}
|
|
58
|
+
mode={mode}
|
|
59
|
+
options={filteredOptions}
|
|
60
|
+
placeholder={t('scc-common-filters:filters.environment.placeholder')}
|
|
61
|
+
listProps={merge<
|
|
62
|
+
FilterProps<ENVIRONMENTS_CODE>['listProps'],
|
|
63
|
+
FilterProps<ENVIRONMENTS_CODE>['listProps']
|
|
64
|
+
>(
|
|
65
|
+
{
|
|
66
|
+
search: { value: inputValue, onInputValueChanged: setInputValue },
|
|
67
|
+
withSelectedTags: true,
|
|
68
|
+
},
|
|
69
|
+
listProps,
|
|
70
|
+
)}
|
|
71
|
+
{...props}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { ENVIRONMENTS_CODE, getEnvironmentByCode } from '@mistertemp/libs-work-environments';
|
|
2
|
+
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import { render } from '../../../../.jest/reactTestingLibrary.config';
|
|
7
|
+
import { EnvironmentFilter } from '../EnvironmentFilter';
|
|
8
|
+
|
|
9
|
+
describe('EnvironmentFilter', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
jest.clearAllMocks();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should display all statuses options', async () => {
|
|
15
|
+
render(<EnvironmentFilter />);
|
|
16
|
+
|
|
17
|
+
fireEvent.click(screen.getByTestId('filters+environment+trigger-button'));
|
|
18
|
+
|
|
19
|
+
Object.values(ENVIRONMENTS_CODE)
|
|
20
|
+
// We can't test everything because of the virtualizer
|
|
21
|
+
.slice(0, 6)
|
|
22
|
+
.forEach((status) => {
|
|
23
|
+
expect(
|
|
24
|
+
screen.getByTestId(`filters+environment_option_${status}`),
|
|
25
|
+
).toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should select one candidate status', async () => {
|
|
30
|
+
const mock = jest.fn();
|
|
31
|
+
|
|
32
|
+
render(<EnvironmentFilter onDropdownValueChanged={mock} />);
|
|
33
|
+
|
|
34
|
+
fireEvent.click(screen.getByTestId('filters+environment+trigger-button'));
|
|
35
|
+
|
|
36
|
+
fireEvent.click(
|
|
37
|
+
screen.getByTestId(
|
|
38
|
+
`filters+environment_${ENVIRONMENTS_CODE.CABINET_MEDICAL}_check_${ENVIRONMENTS_CODE.CABINET_MEDICAL}`,
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
await userEvent.click(document.body);
|
|
43
|
+
|
|
44
|
+
await waitFor(() => {
|
|
45
|
+
expect(mock).toHaveBeenCalledWith([ENVIRONMENTS_CODE.CABINET_MEDICAL]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should display default dropdown value', async () => {
|
|
50
|
+
render(<EnvironmentFilter dropdownValue={[ENVIRONMENTS_CODE.ADDICTOLOGIE]} />);
|
|
51
|
+
|
|
52
|
+
expect(screen.getByTestId('filters+environment+trigger-button-title')).toHaveTextContent(
|
|
53
|
+
getEnvironmentByCode(ENVIRONMENTS_CODE.ADDICTOLOGIE).label,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should update cta title', async () => {
|
|
58
|
+
const newCtaTitle = 'New cta title';
|
|
59
|
+
|
|
60
|
+
render(<EnvironmentFilter withCTA ctaTitle={newCtaTitle} />);
|
|
61
|
+
|
|
62
|
+
fireEvent.click(screen.getByTestId('filters+environment+trigger-button'));
|
|
63
|
+
|
|
64
|
+
expect(screen.getByTestId('filters+environment-bottom-action-button')).toHaveTextContent(
|
|
65
|
+
newCtaTitle,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should update placeholder title', async () => {
|
|
70
|
+
const newPlaceholder = 'New placeholder';
|
|
71
|
+
|
|
72
|
+
render(<EnvironmentFilter placeholder={newPlaceholder} />);
|
|
73
|
+
|
|
74
|
+
expect(screen.getByTestId('filters+environment+trigger-button-title')).toHaveTextContent(
|
|
75
|
+
`${newPlaceholder}`,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should click on cta button', async () => {
|
|
80
|
+
const ctaMock = jest.fn();
|
|
81
|
+
const valueChangeMock = jest.fn();
|
|
82
|
+
|
|
83
|
+
render(
|
|
84
|
+
<EnvironmentFilter
|
|
85
|
+
dropdownValue={[ENVIRONMENTS_CODE.ADDICTOLOGIE]}
|
|
86
|
+
onDropdownCTAClick={ctaMock}
|
|
87
|
+
onDropdownValueChanged={valueChangeMock}
|
|
88
|
+
/>,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
fireEvent.click(screen.getByTestId('filters+environment+trigger-button'));
|
|
92
|
+
|
|
93
|
+
fireEvent.click(screen.getByTestId('filters+environment-bottom-action-button'));
|
|
94
|
+
|
|
95
|
+
expect(ctaMock).toHaveBeenCalledTimes(1);
|
|
96
|
+
expect(valueChangeMock).toHaveBeenCalledTimes(1);
|
|
97
|
+
expect(valueChangeMock).toHaveBeenCalledWith([]);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './EnvironmentFilter';
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Dropdown,
|
|
3
|
+
DropdownProps,
|
|
4
|
+
DropdownSelectorTrigger,
|
|
5
|
+
DropdownSelectorTriggerProps,
|
|
6
|
+
DropdownStaticListProvider,
|
|
7
|
+
DropdownStaticListProviderProps,
|
|
8
|
+
SelectionModes,
|
|
9
|
+
} from '@mistertemp/design-system';
|
|
10
|
+
import isEmpty from 'lodash/isEmpty';
|
|
11
|
+
import isEqual from 'lodash/isEqual';
|
|
12
|
+
import React, { useEffect } from 'react';
|
|
13
|
+
import { useCallback, useState } from 'react';
|
|
14
|
+
|
|
15
|
+
import { useBundledTranslation } from '../../hooks';
|
|
16
|
+
import fr from '../../locales/fr/scc-common-filters.json';
|
|
17
|
+
import it from '../../locales/it/scc-common-filters.json';
|
|
18
|
+
|
|
19
|
+
export type FilterDropdownOption<T extends string = string> = {
|
|
20
|
+
id: T;
|
|
21
|
+
label: string;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type FilterProps<T extends string = string> = Omit<
|
|
26
|
+
DropdownProps,
|
|
27
|
+
'trigger' | 'dropdownValue' | 'onDropdownValueChanged' | 'onOpenChange'
|
|
28
|
+
> & {
|
|
29
|
+
listProps?: Omit<
|
|
30
|
+
DropdownStaticListProviderProps,
|
|
31
|
+
'name' | 'selectionMode' | 'items' | 'itemTemplate' | 'size'
|
|
32
|
+
>;
|
|
33
|
+
selectorProps?: Omit<DropdownSelectorTriggerProps, 'isSelected' | 'title'>;
|
|
34
|
+
name: string;
|
|
35
|
+
'data-testid'?: string;
|
|
36
|
+
placeholder?: string;
|
|
37
|
+
options: Array<FilterDropdownOption<T>>;
|
|
38
|
+
mode?: Extract<SelectionModes, 'single' | 'multiple'>;
|
|
39
|
+
dropdownValue?: T[];
|
|
40
|
+
onDropdownValueChanged?: (value: T[]) => void;
|
|
41
|
+
ctaTitle?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function Filter<T extends string = string>({
|
|
45
|
+
name,
|
|
46
|
+
'data-testid': dataTestId = `filters+${name}`,
|
|
47
|
+
placeholder,
|
|
48
|
+
mode = 'single',
|
|
49
|
+
options,
|
|
50
|
+
onDropdownValueChanged,
|
|
51
|
+
onDropdownCTAClick,
|
|
52
|
+
dropdownValue,
|
|
53
|
+
listProps,
|
|
54
|
+
selectorProps,
|
|
55
|
+
...props
|
|
56
|
+
}: FilterProps<T>) {
|
|
57
|
+
const { t } = useBundledTranslation('scc-common-filters', { fr, it });
|
|
58
|
+
|
|
59
|
+
const [internalDropdownValue, setInternalDropdownValue] = useState<
|
|
60
|
+
FilterDropdownOption<T>[] | undefined
|
|
61
|
+
>(options.filter(({ id }) => dropdownValue?.includes(id)));
|
|
62
|
+
|
|
63
|
+
const handleOnOpenChange = useCallback(
|
|
64
|
+
(isOpen) => {
|
|
65
|
+
if (!isOpen && onDropdownValueChanged && internalDropdownValue) {
|
|
66
|
+
const targetValue = internalDropdownValue.map(({ id }) => id);
|
|
67
|
+
|
|
68
|
+
if (!isEqual(targetValue, dropdownValue)) {
|
|
69
|
+
onDropdownValueChanged(targetValue);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
[internalDropdownValue, dropdownValue, onDropdownValueChanged],
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const isSelected = !isEmpty(internalDropdownValue);
|
|
77
|
+
const displayedValues = internalDropdownValue?.map(({ label }) => label).join(', ') ?? '';
|
|
78
|
+
const triggerTitle = !isEmpty(displayedValues)
|
|
79
|
+
? `${placeholder} : ${displayedValues}`
|
|
80
|
+
: placeholder;
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
setInternalDropdownValue((current) =>
|
|
84
|
+
current?.filter(({ id }) => dropdownValue?.includes(id)),
|
|
85
|
+
);
|
|
86
|
+
}, [dropdownValue]);
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<Dropdown
|
|
90
|
+
data-testid={dataTestId}
|
|
91
|
+
trigger={
|
|
92
|
+
<DropdownSelectorTrigger
|
|
93
|
+
data-testid={`${dataTestId}+trigger`}
|
|
94
|
+
title={triggerTitle}
|
|
95
|
+
isSelected={isSelected}
|
|
96
|
+
type="primary"
|
|
97
|
+
disabled={props.disabled}
|
|
98
|
+
{...selectorProps}
|
|
99
|
+
/>
|
|
100
|
+
}
|
|
101
|
+
withCTA
|
|
102
|
+
size={props?.size ?? 'small'}
|
|
103
|
+
ctaTitle={t('scc-common-filters:actions.removeFilter')}
|
|
104
|
+
dropdownValue={{
|
|
105
|
+
[name]: internalDropdownValue,
|
|
106
|
+
}}
|
|
107
|
+
onDropdownValueChanged={(value) => {
|
|
108
|
+
setInternalDropdownValue(value[name]);
|
|
109
|
+
}}
|
|
110
|
+
onDropdownCTAClick={() => {
|
|
111
|
+
setInternalDropdownValue([]);
|
|
112
|
+
onDropdownCTAClick?.();
|
|
113
|
+
}}
|
|
114
|
+
onOpenChange={handleOnOpenChange}
|
|
115
|
+
{...props}>
|
|
116
|
+
<DropdownStaticListProvider
|
|
117
|
+
data-testid={dataTestId}
|
|
118
|
+
selectionMode={mode}
|
|
119
|
+
items={options}
|
|
120
|
+
name={name}
|
|
121
|
+
size="small"
|
|
122
|
+
itemTemplate={mode === 'multiple' ? 'checkbox' : 'right-checked-list-item'}
|
|
123
|
+
{...listProps}
|
|
124
|
+
/>
|
|
125
|
+
</Dropdown>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Filter';
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Dropdown,
|
|
3
|
+
DropdownSelectorTrigger,
|
|
4
|
+
DropdownStaticListProvider,
|
|
5
|
+
} from '@mistertemp/design-system';
|
|
6
|
+
import { isEqual } from 'lodash';
|
|
7
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
8
|
+
|
|
9
|
+
import { useBundledTranslation, useDebouncedSearchInput } from '../../hooks';
|
|
10
|
+
import fr from '../../locales/fr/scc-common-filters.json';
|
|
11
|
+
import it from '../../locales/it/scc-common-filters.json';
|
|
12
|
+
import { SearchableFilterProps } from '../../typings';
|
|
13
|
+
import type { FilterDropdownOption, FilterProps } from '../Filter';
|
|
14
|
+
import styles from './ProfessionFilter.module.scss';
|
|
15
|
+
|
|
16
|
+
export type ProfessionFilterParentProps = Omit<FilterProps, 'name' | 'options'>;
|
|
17
|
+
|
|
18
|
+
export type ProfessionFilterDropdownOption = FilterDropdownOption;
|
|
19
|
+
|
|
20
|
+
type ProfessionFilterProps = Omit<ProfessionFilterParentProps, 'onDropdownValueChanged'> &
|
|
21
|
+
SearchableFilterProps & {
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
options: FilterDropdownOption[];
|
|
24
|
+
onChangeSelectedOptions?: (options: FilterDropdownOption[]) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const ProfessionFilter = ({
|
|
28
|
+
dropdownValue,
|
|
29
|
+
onDropdownCTAClick,
|
|
30
|
+
isLoading,
|
|
31
|
+
onChangeSearchText,
|
|
32
|
+
options,
|
|
33
|
+
onChangeSelectedOptions,
|
|
34
|
+
placeholder,
|
|
35
|
+
searchConfig,
|
|
36
|
+
'data-testid': dataTestId = 'filters+profession',
|
|
37
|
+
...props
|
|
38
|
+
}: ProfessionFilterProps) => {
|
|
39
|
+
const { t } = useBundledTranslation('scc-common-filters', { fr, it });
|
|
40
|
+
|
|
41
|
+
const [searchInput, setSearchInput] = useState('');
|
|
42
|
+
const [sortedOptions, setSortedOptions] = useState<FilterDropdownOption[]>([]);
|
|
43
|
+
const searchValue = useDebouncedSearchInput(searchInput, searchConfig);
|
|
44
|
+
|
|
45
|
+
const selectedOptions = useRef<FilterDropdownOption[]>([]);
|
|
46
|
+
const triggerLabel = useRef<string>('');
|
|
47
|
+
const selectionHasChanged = useRef<boolean>(false);
|
|
48
|
+
|
|
49
|
+
const placeholderText = useMemo(
|
|
50
|
+
() => placeholder ?? t('scc-common-filters:filters.profession.placeholder'),
|
|
51
|
+
[placeholder, t],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const handleDropdownValueChanged = (values: { profession: FilterDropdownOption[] }) => {
|
|
55
|
+
if (!options) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!isEqual(selectedOptions.current, values.profession)) {
|
|
60
|
+
selectionHasChanged.current = true;
|
|
61
|
+
selectedOptions.current = values.profession;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const handleOpenChanged = useCallback(
|
|
66
|
+
(isOpen) => {
|
|
67
|
+
if (!isOpen && selectionHasChanged.current) {
|
|
68
|
+
selectionHasChanged.current = false;
|
|
69
|
+
onChangeSelectedOptions?.(selectedOptions.current);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
//eslint-disable-next-line react-hooks/exhaustive-deps
|
|
73
|
+
[dropdownValue], // -> onDropdownValueChanged in deps list creates an infinite loop when used in a FilterGroup*/
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const handleCTAClicked = () => {
|
|
77
|
+
onChangeSelectedOptions?.([]);
|
|
78
|
+
onDropdownCTAClick?.();
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
triggerLabel.current = placeholderText;
|
|
83
|
+
|
|
84
|
+
if (!selectionHasChanged.current) {
|
|
85
|
+
selectedOptions.current = options.filter(({ id }) => dropdownValue?.includes(id));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (selectedOptions.current.length) {
|
|
89
|
+
const selectedProfessionLabels = selectedOptions.current
|
|
90
|
+
.map(({ label }) => label)
|
|
91
|
+
.join(', ');
|
|
92
|
+
|
|
93
|
+
triggerLabel.current += `: ${selectedProfessionLabels}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sortedOptionValues = selectedOptions.current.map(({ id }) => id);
|
|
97
|
+
const remainingOptions = options.filter(({ id }) => !sortedOptionValues.includes(id));
|
|
98
|
+
|
|
99
|
+
setSortedOptions([...selectedOptions.current, ...remainingOptions]);
|
|
100
|
+
}, [dropdownValue, options, placeholderText]);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
onChangeSearchText?.(searchValue);
|
|
104
|
+
}, [searchValue, onChangeSearchText]);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<Dropdown<{ profession: FilterDropdownOption[] }>
|
|
108
|
+
trigger={
|
|
109
|
+
<DropdownSelectorTrigger
|
|
110
|
+
data-testid={`${dataTestId}+trigger`}
|
|
111
|
+
title={triggerLabel.current}
|
|
112
|
+
isSelected={!!dropdownValue?.length}
|
|
113
|
+
type="primary"
|
|
114
|
+
disabled={props.disabled}
|
|
115
|
+
/>
|
|
116
|
+
}
|
|
117
|
+
data-testid={dataTestId}
|
|
118
|
+
withCTA
|
|
119
|
+
size="small"
|
|
120
|
+
ctaTitle={t('scc-common-filters:actions.removeFilter')}
|
|
121
|
+
dropdownValue={{ profession: selectedOptions.current }}
|
|
122
|
+
onDropdownCTAClick={handleCTAClicked}
|
|
123
|
+
onDropdownValueChanged={handleDropdownValueChanged}
|
|
124
|
+
onOpenChange={handleOpenChanged}
|
|
125
|
+
{...props}>
|
|
126
|
+
<div className={styles.container}>
|
|
127
|
+
<DropdownStaticListProvider
|
|
128
|
+
search={{
|
|
129
|
+
value: searchInput,
|
|
130
|
+
isLoading,
|
|
131
|
+
placeholder: t('scc-common-filters:filters.profession.search.placeholder'),
|
|
132
|
+
onInputValueChanged: (inputValue) => setSearchInput(inputValue ?? ''),
|
|
133
|
+
'data-testid': `${dataTestId}+search`,
|
|
134
|
+
}}
|
|
135
|
+
data-testid={dataTestId}
|
|
136
|
+
selectionMode="multiple"
|
|
137
|
+
items={sortedOptions}
|
|
138
|
+
name="profession"
|
|
139
|
+
size="small"
|
|
140
|
+
itemTemplate="checkbox"
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
</Dropdown>
|
|
144
|
+
);
|
|
145
|
+
};
|