Skip to content

Commit 6426e81

Browse files
authored
feat: UNIC-36 add InputSearch for tables (#20)
1 parent b8de562 commit 6426e81

File tree

14 files changed

+667
-11501
lines changed

14 files changed

+667
-11501
lines changed

.trivyignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
# cross-spawn HIGH
1+
#cross-spawn (package.json) │ CVE-2024-21538 │ HIGH
22
CVE-2024-21538

package-lock.json

+447-11,423
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
},
2525
"dependencies": {
2626
"@apollo/client": "^3.12.0-rc.4",
27-
"@ferlab/ui": "^10.15.5",
27+
"@ferlab/ui": "^10.17.0",
2828
"@reduxjs/toolkit": "^2.3.0",
2929
"antd": "4.24.16",
3030
"axios": "^1.7.8",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2+
import debounce from 'lodash/debounce';
3+
4+
import InputSearch from './InputSearch';
5+
6+
jest.mock('lodash/debounce', () =>
7+
jest.fn((fn) => {
8+
fn.cancel = jest.fn();
9+
return fn;
10+
}),
11+
);
12+
13+
describe('InputSearch Component', () => {
14+
const mockHandleSetVariables = jest.fn();
15+
16+
const defaultProps = {
17+
searchFields: ['field1', 'field2'],
18+
handleSetVariables: mockHandleSetVariables,
19+
variables: {},
20+
title: 'Test Search',
21+
placeholder: 'Search something',
22+
};
23+
24+
beforeEach(() => {
25+
mockHandleSetVariables.mockClear();
26+
});
27+
28+
it('renders correctly with given props', () => {
29+
render(<InputSearch {...defaultProps} />);
30+
31+
expect(screen.getByText('Test Search')).toBeInTheDocument();
32+
expect(screen.getByPlaceholderText('Search something')).toBeInTheDocument();
33+
});
34+
35+
it('updates state when typing', () => {
36+
render(<InputSearch {...defaultProps} />);
37+
const input = screen.getByPlaceholderText('Search something');
38+
39+
fireEvent.change(input, { target: { value: 'abc' } });
40+
41+
expect(input).toHaveValue('abc');
42+
});
43+
44+
it('calls handleSetVariables on onPressEnter', async () => {
45+
render(<InputSearch {...defaultProps} />);
46+
const input = screen.getByPlaceholderText('Search something');
47+
fireEvent.change(input, { target: { value: 'te' } });
48+
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 });
49+
expect(mockHandleSetVariables).toHaveBeenCalledTimes(1);
50+
expect(mockHandleSetVariables).toHaveBeenCalledWith({
51+
or: [
52+
{ field: 'field1', value: '*te*', useWildcard: true },
53+
{ field: 'field2', value: '*te*', useWildcard: true },
54+
],
55+
});
56+
});
57+
58+
it('debounces search after typing 3+ chars', async () => {
59+
render(<InputSearch {...defaultProps} />);
60+
const input = screen.getByPlaceholderText('Search something');
61+
62+
fireEvent.change(input, { target: { value: 'tes' } });
63+
64+
await waitFor(() => {
65+
expect(mockHandleSetVariables).toHaveBeenCalledWith({
66+
or: [
67+
{ field: 'field1', value: '*tes*', useWildcard: true },
68+
{ field: 'field2', value: '*tes*', useWildcard: true },
69+
],
70+
});
71+
});
72+
});
73+
74+
it('clears search when variables are reset', async () => {
75+
const { rerender } = render(<InputSearch {...defaultProps} />);
76+
const input = screen.getByPlaceholderText('Search something');
77+
fireEvent.change(input, { target: { value: 'test' } });
78+
expect(input).toHaveValue('test');
79+
rerender(<InputSearch {...defaultProps} variables={{ or: [] }} />);
80+
expect(input).toHaveValue('');
81+
});
82+
83+
it('cancels debounce on unmount', () => {
84+
const { unmount } = render(<InputSearch {...defaultProps} />);
85+
const input = screen.getByPlaceholderText('Search something');
86+
fireEvent.change(input, { target: { value: 'tes' } });
87+
unmount();
88+
expect(jest.mocked(debounce).mock.results[0].value.cancel).toHaveBeenCalled();
89+
});
90+
91+
it('clears search when empty value is entered', async () => {
92+
const { rerender } = render(<InputSearch {...defaultProps} />);
93+
const input = screen.getByPlaceholderText('Search something');
94+
fireEvent.change(input, { target: { value: 'test' } });
95+
expect(input).toHaveValue('test');
96+
rerender(<InputSearch {...defaultProps} variables={{ or: [] }} />);
97+
fireEvent.change(input, { target: { value: '' } });
98+
expect(input).toHaveValue('');
99+
});
100+
});

src/components/CatalogTables/InputSearch/InputSearch.tsx

+38-42
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,78 @@
11
import { SearchOutlined } from '@ant-design/icons';
22
import { Input, Typography } from 'antd';
33
import debounce from 'lodash/debounce';
4-
import React, { useState } from 'react';
4+
import React, { useEffect, useState } from 'react';
55

66
import { QueryOptions } from '@/types/queries';
77

88
import styles from './InputSearch.module.css';
99

1010
const { Text } = Typography;
1111

12-
//TODO: FREE SEARCH IS FOR UNICWEB-36
13-
// const search_fields = [
14-
// 'rs_code',
15-
// 'rs_description_en',
16-
// 'rs_description_fr',
17-
// 'rs_name',
18-
// 'rs_project_pi',
19-
// 'rs_projet_erb',
20-
// 'rs_title',
21-
// ];
2212
const InputSearch = ({
23-
search_fields,
13+
searchFields,
2414
handleSetVariables,
2515
variables,
2616
title,
2717
placeholder,
2818
}: {
29-
search_fields: string[];
19+
searchFields: string[];
3020
handleSetVariables: any;
3121
variables: QueryOptions;
3222
title: string;
3323
placeholder: string;
3424
}) => {
35-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
3625
const [search, setSearch] = useState('');
3726

38-
const onChangeSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
39-
const charsCount = e.target.value.length;
40-
/** According to analyse, trigger search only with 3 chars (or 0 to reset) */
41-
if (charsCount === 0 || charsCount > 2) {
42-
setSearch(e.target.value);
43-
handleSearch(e.target.value);
44-
}
45-
};
46-
const onKeyUpSearch = (e: any) => {
47-
if (e.key === 'Enter') {
48-
setSearch(e.target.value);
49-
handleSearch(e.target.value);
50-
}
27+
const handleSearch = (_search: string) => {
28+
/** add all searchFields as OR with wildcard on */
29+
const or = _search ? searchFields.map((field) => ({ field, value: `*${_search}*`, useWildcard: true })) : [];
30+
const _variables = { ...variables, or };
31+
handleSetVariables(_variables);
5132
};
5233

53-
const handleSearch = (_search: string) => {
54-
/** add all search_fields with ES fuzzy and wildcard */
55-
const match = _search
56-
? search_fields.map((field) => ({ field, value: `*${_search}*`, useFuzzy: true, useWildcard: true }))
57-
: undefined;
34+
const debouncedHandleSearch = debounce(handleSearch, 500);
5835

59-
const _variables = { ...variables, match };
36+
const onChangeSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
37+
setSearch(e.target.value);
38+
};
6039

61-
handleSetVariables(_variables);
40+
const onPressEnter = () => {
41+
debouncedHandleSearch.cancel(); // Cancel debounce on Enter
42+
handleSearch(search);
6243
};
6344

64-
/** reset selects when variables is reset by parent component fn handleClearFilters */
65-
// useEffect(() => {
66-
// if (!variables?.match?.length) {
67-
// setSearch('');
68-
// }
69-
// }, [variables]);
45+
useEffect(() => {
46+
const charsCount = search.length;
47+
const hasSearchInVariables = variables?.or?.some(({ field }) => searchFields.includes(field));
48+
/** According to analyse, trigger search only with 3 chars (or 0 to reset) and after 500ms */
49+
if ((charsCount === 0 && hasSearchInVariables) || charsCount > 2) {
50+
debouncedHandleSearch(search);
51+
}
52+
return () => {
53+
debouncedHandleSearch.cancel(); // Clear pending debounce on unmount or rerender
54+
};
55+
/** Need to skip deps but search to avoid loop */
56+
//eslint-disable-next-line react-hooks/exhaustive-deps
57+
}, [search]);
58+
59+
/** Reset search when variables are cleared by parent */
60+
useEffect(() => {
61+
if (!variables?.orGroups?.length && !variables?.match?.length && !variables?.or?.length) {
62+
setSearch('');
63+
}
64+
}, [variables]);
7065

7166
return (
7267
<div className={styles.filter}>
7368
<Text className={styles.title}>{title}</Text>
7469
<Input
7570
placeholder={placeholder}
76-
onChange={debounce(onChangeSearch, 500)}
77-
onKeyUp={onKeyUpSearch}
71+
onChange={onChangeSearch}
72+
onPressEnter={onPressEnter}
7873
suffix={<SearchOutlined className={styles.icon} />}
7974
allowClear
75+
value={search}
8076
/>
8177
</div>
8278
);

src/components/CatalogTables/ResourcesTable/ResourcesTable.test.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,10 @@ describe('ResourcesTable', () => {
147147
request: {
148148
query: GET_RESOURCES,
149149
variables: {
150-
sort: [{ field: 'rs_name', order: 'asc' }],
150+
sort: [
151+
{ field: 'rs_name', order: 'asc' },
152+
{ field: 'rs_id', order: 'asc' },
153+
],
151154
size: DEFAULT_PAGE_SIZE,
152155
search_after: undefined,
153156
},

src/components/CatalogTables/ResourcesTable/ResourcesTable.tsx

+19-3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import React, { useEffect, useState } from 'react';
77
import intl from 'react-intl-universal';
88
import { useDispatch } from 'react-redux';
99

10+
import InputSearch from '@/components/CatalogTables/InputSearch';
1011
import InputSelect from '@/components/CatalogTables/InputSelect';
1112
import { GET_RESOURCES } from '@/lib/graphql/queries/getResources';
1213
import { useLang } from '@/store/global';
@@ -30,6 +31,16 @@ import styles from './ResourcesTable.module.css';
3031

3132
const SCROLL_WRAPPER_ID = 'resources-table-scroll-wrapper';
3233

34+
const searchFields = [
35+
'rs_code',
36+
'rs_description_en',
37+
'rs_description_fr',
38+
'rs_name',
39+
'rs_project_pi',
40+
'rs_projet_erb',
41+
'rs_title',
42+
];
43+
3344
const ResourcesTable = () => {
3445
const lang = useLang();
3546
const { userInfo } = useUser();
@@ -77,8 +88,7 @@ const ResourcesTable = () => {
7788
//TODO Do it for UNICWEB-41
7889
};
7990

80-
//TODO adjusted it for UNICWEB-36
81-
const hasFilter = !!variables.orGroups?.length || !!variables.match?.length;
91+
const hasFilter = !!variables.orGroups?.length || !!variables.match?.length || !!variables.or?.length;
8292
const handleClearFilters = () => {
8393
setVariables(initialVariables);
8494
/** reset pagination on filters changes */
@@ -114,7 +124,13 @@ const ResourcesTable = () => {
114124
return (
115125
<div className={styles.container}>
116126
<div className={styles.filtersRow}>
117-
{/*//TODO: ADD InputSearch here for UNICWEB-36*/}
127+
<InputSearch
128+
searchFields={searchFields}
129+
handleSetVariables={handleSetVariables}
130+
variables={variables}
131+
title={intl.get('entities.resource.Resource')}
132+
placeholder={intl.get('entities.resource.filterBy')}
133+
/>
118134
<InputSelect
119135
operator={'orGroups'}
120136
mode={'tags'}

src/components/CatalogTables/TablesTable/TablesTable.test.tsx

+8-2
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,10 @@ describe('TablesTable', () => {
112112
request: {
113113
query: GET_TABLES,
114114
variables: {
115-
sort: [{ field: 'tab_name', order: 'asc' }],
115+
sort: [
116+
{ field: 'tab_name', order: 'asc' },
117+
{ field: 'tab_id', order: 'asc' },
118+
],
116119
size: DEFAULT_PAGE_SIZE,
117120
search_after: undefined,
118121
},
@@ -148,7 +151,10 @@ describe('TablesTable', () => {
148151
request: {
149152
query: GET_TABLES,
150153
variables: {
151-
sort: [{ field: 'tab_name', order: 'asc' }],
154+
sort: [
155+
{ field: 'tab_name', order: 'asc' },
156+
{ field: 'tab_id', order: 'asc' },
157+
],
152158
size: DEFAULT_PAGE_SIZE,
153159
search_after: undefined,
154160
},

src/components/CatalogTables/TablesTable/TablesTable.tsx

+11-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import React, { useEffect, useState } from 'react';
77
import intl from 'react-intl-universal';
88
import { useDispatch } from 'react-redux';
99

10+
import InputSearch from '@/components/CatalogTables/InputSearch';
1011
import InputSelect from '@/components/CatalogTables/InputSelect';
1112
import styles from '@/components/CatalogTables/ResourcesTable/ResourcesTable.module.css';
1213
import { GET_TABLES } from '@/lib/graphql/queries/getTables';
@@ -30,6 +31,8 @@ import getColumns from './getColumns';
3031

3132
const SCROLL_WRAPPER_ID = 'tables-table-scroll-wrapper';
3233

34+
const searchFields = ['tab_label_en', 'tab_label_fr', 'tab_name'];
35+
3336
const TablesTable = () => {
3437
const lang = useLang();
3538
const { userInfo } = useUser();
@@ -74,8 +77,7 @@ const TablesTable = () => {
7477
const [rsTypeOptions, setRsTypeOptions] = useState<SelectProps['options']>();
7578
const [rsNameOptions, setRsNameOptions] = useState<SelectProps['options']>();
7679

77-
//TODO adjusted it for UNICWEB-36
78-
const hasFilter = !!variables.orGroups?.length || !!variables.match?.length;
80+
const hasFilter = !!variables.orGroups?.length || !!variables.match?.length || !!variables.or?.length;
7981
const handleClearFilters = () => {
8082
setVariables(initialVariables);
8183
/** reset pagination on filters changes */
@@ -117,6 +119,13 @@ const TablesTable = () => {
117119
return (
118120
<div className={styles.container}>
119121
<div className={styles.filtersRow}>
122+
<InputSearch
123+
searchFields={searchFields}
124+
handleSetVariables={handleSetVariables}
125+
variables={variables}
126+
title={intl.get('entities.table.Table')}
127+
placeholder={intl.get('entities.table.filterBy')}
128+
/>
120129
<InputSelect
121130
operator={'match'}
122131
options={rsNameOptions}

0 commit comments

Comments
 (0)