ui-soxo-bootstrap-core 2.6.62-dev.4 → 2.6.63-dev.0
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/core/lib/components/index.js +13 -0
- package/core/lib/elements/basic/table/column-filter/column-filter.js +299 -0
- package/core/lib/elements/basic/table/column-filter/column-filter.scss +48 -0
- package/core/lib/elements/basic/table/column-filter/column-filter.test.js +148 -0
- package/core/lib/elements/basic/table/readme.md +50 -1
- package/core/lib/elements/basic/table/table.js +59 -4
- package/core/lib/elements/basic/table/table.test.js +80 -0
- package/package.json +1 -1
|
@@ -56,6 +56,14 @@ import TableWrapper from './table-wrapper/table-wrapper'
|
|
|
56
56
|
|
|
57
57
|
import TableComponent from '../elements/basic/table/table'
|
|
58
58
|
|
|
59
|
+
import {
|
|
60
|
+
withColumnFilters,
|
|
61
|
+
applyColumnFilters,
|
|
62
|
+
hasColumnFilters,
|
|
63
|
+
getColumnFilterValue,
|
|
64
|
+
ColumnFilterDropdown,
|
|
65
|
+
} from '../elements/basic/table/column-filter/column-filter'
|
|
66
|
+
|
|
59
67
|
import CustomActions from '../models/actions/components/custom-actions/custom-actions'
|
|
60
68
|
|
|
61
69
|
// Generic Components Ends
|
|
@@ -154,6 +162,11 @@ export {
|
|
|
154
162
|
|
|
155
163
|
TableWrapper,
|
|
156
164
|
TableComponent,
|
|
165
|
+
withColumnFilters,
|
|
166
|
+
applyColumnFilters,
|
|
167
|
+
hasColumnFilters,
|
|
168
|
+
getColumnFilterValue,
|
|
169
|
+
ColumnFilterDropdown,
|
|
157
170
|
CustomActions,
|
|
158
171
|
|
|
159
172
|
// Generic Components Ends
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Excel style column filter for tables
|
|
3
|
+
*
|
|
4
|
+
* Each filterable column gets a dropdown listing the distinct values of that column,
|
|
5
|
+
* where one or more values can be picked (with Select All / Clear All). Filters of
|
|
6
|
+
* multiple columns can be active at the same time, a record is shown only when it
|
|
7
|
+
* matches the filter of every column.
|
|
8
|
+
*
|
|
9
|
+
* The values are the text shown in the cell, read from the column render (or the
|
|
10
|
+
* exportDefinition when the cell is a component, ex: status dropdowns), else the
|
|
11
|
+
* dataIndex value of the record.
|
|
12
|
+
*
|
|
13
|
+
* A column is left out with `filterable: false`.
|
|
14
|
+
*/
|
|
15
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
16
|
+
|
|
17
|
+
import { Checkbox, Input, Empty } from 'antd';
|
|
18
|
+
|
|
19
|
+
import { FilterFilled } from '@ant-design/icons';
|
|
20
|
+
|
|
21
|
+
import { useTranslation } from 'react-i18next';
|
|
22
|
+
|
|
23
|
+
import Button from '../../button/button';
|
|
24
|
+
|
|
25
|
+
import './column-filter.scss';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Text of a rendered cell, ex: <Tag>Male</Tag> gives Male
|
|
29
|
+
*
|
|
30
|
+
* @param {*} node
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
function nodeToText(node) {
|
|
34
|
+
if (node === null || node === undefined || typeof node === 'boolean') return '';
|
|
35
|
+
|
|
36
|
+
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
|
37
|
+
|
|
38
|
+
if (Array.isArray(node)) return node.map(nodeToText).join('');
|
|
39
|
+
|
|
40
|
+
if (React.isValidElement(node)) return nodeToText(node.props.children);
|
|
41
|
+
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Value of the dataIndex in the record, dataIndex can be a path array
|
|
47
|
+
*
|
|
48
|
+
* @param {Object} record
|
|
49
|
+
* @param {string|Array} dataIndex
|
|
50
|
+
* @returns {*}
|
|
51
|
+
*/
|
|
52
|
+
function getDataIndexValue(record, dataIndex) {
|
|
53
|
+
const path = Array.isArray(dataIndex) ? dataIndex : [dataIndex];
|
|
54
|
+
|
|
55
|
+
return path.reduce((value, key) => (value === null || value === undefined ? value : value[key]), record);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Key the filter of the column is stored against
|
|
60
|
+
*
|
|
61
|
+
* @param {Object} column
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
export function getColumnFilterKey(column) {
|
|
65
|
+
if (column.key !== undefined && column.key !== null) return String(column.key);
|
|
66
|
+
|
|
67
|
+
if (column.dataIndex !== undefined && column.dataIndex !== null) return [].concat(column.dataIndex).join('.');
|
|
68
|
+
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Value of the column for the record, used to list and match the filter values
|
|
74
|
+
*
|
|
75
|
+
* @param {Object} column
|
|
76
|
+
* @param {Object} record
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
export function getColumnFilterValue(column, record) {
|
|
80
|
+
let text = '';
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const value = column.dataIndex !== undefined ? getDataIndexValue(record, column.dataIndex) : record;
|
|
84
|
+
|
|
85
|
+
text = column.render ? nodeToText(column.render(value, record, 0)) : nodeToText(value);
|
|
86
|
+
|
|
87
|
+
// Cells rendered as a component (ex: status change) have no text, use the export value
|
|
88
|
+
if (!text && column.exportDefinition) text = nodeToText(column.exportDefinition(record));
|
|
89
|
+
} catch (error) {
|
|
90
|
+
text = '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return String(text).replace(/\s+/g, ' ').trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether the column can be filtered
|
|
98
|
+
*
|
|
99
|
+
* @param {Object} column
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
export function isFilterableColumn(column) {
|
|
103
|
+
if (column.filterable === false) return false;
|
|
104
|
+
|
|
105
|
+
return !!getColumnFilterKey(column) && !!(column.render || column.exportDefinition || column.dataIndex !== undefined);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Records matching the filter of every column
|
|
110
|
+
*
|
|
111
|
+
* @param {Array} records
|
|
112
|
+
* @param {Array} columns
|
|
113
|
+
* @param {Object} filters - Selected values against the column key, ex: { gender: ['Male'] }
|
|
114
|
+
* @param {string} exceptKey - Key of the column to leave out (to list the values of that column)
|
|
115
|
+
* @returns {Array}
|
|
116
|
+
*/
|
|
117
|
+
export function applyColumnFilters(records = [], columns = [], filters = {}, exceptKey) {
|
|
118
|
+
const active = columns.filter((column) => {
|
|
119
|
+
const key = getColumnFilterKey(column);
|
|
120
|
+
|
|
121
|
+
return key !== exceptKey && isFilterableColumn(column) && Array.isArray(filters[key]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!active.length) return records;
|
|
125
|
+
|
|
126
|
+
return records.filter((record) => active.every((column) => filters[getColumnFilterKey(column)].includes(getColumnFilterValue(column, record))));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Whether any column filter is applied
|
|
131
|
+
*
|
|
132
|
+
* @param {Object} filters
|
|
133
|
+
* @returns {boolean}
|
|
134
|
+
*/
|
|
135
|
+
export function hasColumnFilters(filters = {}) {
|
|
136
|
+
return Object.values(filters || {}).some((values) => Array.isArray(values));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Distinct values of the column in the records, sorted
|
|
141
|
+
*
|
|
142
|
+
* @param {Object} column
|
|
143
|
+
* @param {Array} records
|
|
144
|
+
* @returns {Array<string>}
|
|
145
|
+
*/
|
|
146
|
+
export function getColumnValues(column, records = []) {
|
|
147
|
+
const values = new Set(records.map((record) => getColumnFilterValue(column, record)));
|
|
148
|
+
|
|
149
|
+
return [...values].sort((a, b) => {
|
|
150
|
+
// Blanks are listed at the end
|
|
151
|
+
if (!a) return 1;
|
|
152
|
+
if (!b) return -1;
|
|
153
|
+
|
|
154
|
+
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Dropdown with the values of the column
|
|
160
|
+
*
|
|
161
|
+
* @param {Object} column - Column being filtered
|
|
162
|
+
* @param {Array} records - Records to list the values from
|
|
163
|
+
* @param {Array} applied - Values applied now, undefined when the column is not filtered
|
|
164
|
+
* @param {Function} onApply - Called with the selected values, undefined to remove the filter
|
|
165
|
+
* @param {Function} close - Closes the dropdown
|
|
166
|
+
* @param {boolean} visible - Whether the dropdown is open
|
|
167
|
+
*/
|
|
168
|
+
export function ColumnFilterDropdown({ column, records, applied, onApply, close, visible }) {
|
|
169
|
+
const { t } = useTranslation();
|
|
170
|
+
|
|
171
|
+
const [search, setSearch] = useState('');
|
|
172
|
+
|
|
173
|
+
const [selected, setSelected] = useState([]);
|
|
174
|
+
|
|
175
|
+
const options = useMemo(() => (visible ? getColumnValues(column, records) : []), [visible, column, records]);
|
|
176
|
+
|
|
177
|
+
// Every time the dropdown opens start from the applied filter, all values are selected when there is no filter
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (visible) {
|
|
180
|
+
setSearch('');
|
|
181
|
+
setSelected(applied ? applied : options);
|
|
182
|
+
}
|
|
183
|
+
}, [visible]);
|
|
184
|
+
|
|
185
|
+
const visibleOptions = options.filter((value) => (value || t('(Blanks)')).toLowerCase().includes(search.trim().toLowerCase()));
|
|
186
|
+
|
|
187
|
+
const selectedVisible = visibleOptions.filter((value) => selected.includes(value));
|
|
188
|
+
|
|
189
|
+
const allSelected = visibleOptions.length > 0 && selectedVisible.length === visibleOptions.length;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Select / unselect all the listed values
|
|
193
|
+
*/
|
|
194
|
+
function onSelectAll(event) {
|
|
195
|
+
if (event.target.checked) {
|
|
196
|
+
setSelected([...new Set([...selected, ...visibleOptions])]);
|
|
197
|
+
} else {
|
|
198
|
+
setSelected(selected.filter((value) => !visibleOptions.includes(value)));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function onToggle(value, checked) {
|
|
203
|
+
setSelected(checked ? [...selected, value] : selected.filter((ele) => ele !== value));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Apply the selected values, selecting every value removes the filter of the column
|
|
208
|
+
*/
|
|
209
|
+
function onOk() {
|
|
210
|
+
const isEveryValue = options.every((value) => selected.includes(value));
|
|
211
|
+
|
|
212
|
+
onApply(isEveryValue ? undefined : options.filter((value) => selected.includes(value)));
|
|
213
|
+
|
|
214
|
+
// close() instead of confirm(), confirm makes antd reset the pagination to the first page
|
|
215
|
+
if (close) close();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<div className="column-filter-dropdown" onKeyDown={(event) => event.stopPropagation()}>
|
|
220
|
+
<Input
|
|
221
|
+
size="small"
|
|
222
|
+
allowClear
|
|
223
|
+
placeholder={t('Search')}
|
|
224
|
+
value={search}
|
|
225
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
226
|
+
className="column-filter-search"
|
|
227
|
+
/>
|
|
228
|
+
|
|
229
|
+
{visibleOptions.length ? (
|
|
230
|
+
<>
|
|
231
|
+
<Checkbox
|
|
232
|
+
className="column-filter-select-all"
|
|
233
|
+
checked={allSelected}
|
|
234
|
+
indeterminate={selectedVisible.length > 0 && !allSelected}
|
|
235
|
+
onChange={onSelectAll}
|
|
236
|
+
>
|
|
237
|
+
{t('Select All')}
|
|
238
|
+
</Checkbox>
|
|
239
|
+
|
|
240
|
+
<div className="column-filter-options">
|
|
241
|
+
{visibleOptions.map((value) => (
|
|
242
|
+
<Checkbox key={value || '__blank__'} checked={selected.includes(value)} onChange={(event) => onToggle(value, event.target.checked)}>
|
|
243
|
+
{value || t('(Blanks)')}
|
|
244
|
+
</Checkbox>
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
</>
|
|
248
|
+
) : (
|
|
249
|
+
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('No values')} />
|
|
250
|
+
)}
|
|
251
|
+
|
|
252
|
+
<div className="column-filter-actions">
|
|
253
|
+
<Button size="small" type="secondary" onClick={() => setSelected([])}>
|
|
254
|
+
{t('Clear All')}
|
|
255
|
+
</Button>
|
|
256
|
+
|
|
257
|
+
<Button size="small" type="primary" disabled={!selected.length} onClick={onOk}>
|
|
258
|
+
{t('OK')}
|
|
259
|
+
</Button>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Add the Excel style filter to the columns
|
|
267
|
+
*
|
|
268
|
+
* @param {Array} columns
|
|
269
|
+
* @param {Array} records - Records the values are listed from
|
|
270
|
+
* @param {Object} filters - Applied filters against the column key
|
|
271
|
+
* @param {Function} onChange - Called with the column key and the selected values (undefined to remove)
|
|
272
|
+
* @returns {Array}
|
|
273
|
+
*/
|
|
274
|
+
export function withColumnFilters(columns = [], records = [], filters = {}, onChange) {
|
|
275
|
+
return columns.map((column) => {
|
|
276
|
+
if (!isFilterableColumn(column)) return column;
|
|
277
|
+
|
|
278
|
+
const key = getColumnFilterKey(column);
|
|
279
|
+
|
|
280
|
+
const applied = filters[key];
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
...column,
|
|
284
|
+
filteredValue: applied || null,
|
|
285
|
+
filterIcon: () => <FilterFilled className={`column-filter-icon ${applied ? 'column-filter-icon--active' : ''}`} />,
|
|
286
|
+
filterDropdown: ({ close, visible }) => (
|
|
287
|
+
<ColumnFilterDropdown
|
|
288
|
+
column={column}
|
|
289
|
+
// Values are listed from the records matching the filters of the other columns
|
|
290
|
+
records={applyColumnFilters(records, columns, filters, key)}
|
|
291
|
+
applied={applied}
|
|
292
|
+
visible={visible}
|
|
293
|
+
close={close}
|
|
294
|
+
onApply={(values) => onChange(key, values)}
|
|
295
|
+
/>
|
|
296
|
+
),
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* Excel style column filter of the table */
|
|
2
|
+
|
|
3
|
+
.column-filter-dropdown {
|
|
4
|
+
width: 240px;
|
|
5
|
+
padding: 8px;
|
|
6
|
+
|
|
7
|
+
.column-filter-search {
|
|
8
|
+
margin-bottom: 8px;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.column-filter-select-all {
|
|
12
|
+
width: 100%;
|
|
13
|
+
padding: 4px 0 6px;
|
|
14
|
+
border-bottom: 1px solid #f0f0f0;
|
|
15
|
+
font-weight: 600;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.column-filter-options {
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-direction: column;
|
|
21
|
+
max-height: 220px;
|
|
22
|
+
padding: 6px 0;
|
|
23
|
+
overflow-y: auto;
|
|
24
|
+
scrollbar-width: thin;
|
|
25
|
+
|
|
26
|
+
.ant-checkbox-wrapper {
|
|
27
|
+
margin: 0;
|
|
28
|
+
padding: 3px 0;
|
|
29
|
+
word-break: break-word;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.column-filter-actions {
|
|
34
|
+
display: flex;
|
|
35
|
+
justify-content: space-between;
|
|
36
|
+
gap: 8px;
|
|
37
|
+
padding-top: 8px;
|
|
38
|
+
border-top: 1px solid #f0f0f0;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.column-filter-icon {
|
|
43
|
+
color: #bfbfbf;
|
|
44
|
+
|
|
45
|
+
&--active {
|
|
46
|
+
color: #1677ff;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
getColumnFilterKey,
|
|
6
|
+
getColumnFilterValue,
|
|
7
|
+
isFilterableColumn,
|
|
8
|
+
applyColumnFilters,
|
|
9
|
+
hasColumnFilters,
|
|
10
|
+
getColumnValues,
|
|
11
|
+
withColumnFilters,
|
|
12
|
+
ColumnFilterDropdown,
|
|
13
|
+
} from './column-filter';
|
|
14
|
+
|
|
15
|
+
jest.mock('react-i18next', () => ({
|
|
16
|
+
useTranslation: () => ({ t: (text) => text }),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
// The core Button reads the theme from the global store, a plain button is enough here
|
|
20
|
+
jest.mock('../../button/button', () => ({ children, onClick, disabled }) => (
|
|
21
|
+
<button type="button" onClick={onClick} disabled={disabled}>
|
|
22
|
+
{children}
|
|
23
|
+
</button>
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
const genderColumn = { key: 'gender', title: 'Gender', render: (record) => <span>{record.gender}</span> };
|
|
27
|
+
|
|
28
|
+
const cityColumn = { key: 'city', title: 'City', dataIndex: ['address', 'city'] };
|
|
29
|
+
|
|
30
|
+
const statusColumn = { key: 'status', title: 'Status', render: () => <select />, exportDefinition: (record) => record.status };
|
|
31
|
+
|
|
32
|
+
const records = [
|
|
33
|
+
{ id: 1, gender: 'Male', address: { city: 'Calicut' }, status: 'Booked' },
|
|
34
|
+
{ id: 2, gender: 'Female', address: { city: 'Kochi' }, status: 'Arrived' },
|
|
35
|
+
{ id: 3, gender: 'Male', address: { city: 'Kochi' }, status: 'Booked' },
|
|
36
|
+
{ id: 4, gender: '', address: { city: 'Calicut' }, status: 'Arrived' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
describe('column filter helpers', () => {
|
|
40
|
+
it('reads the rendered text of the cell', () => {
|
|
41
|
+
expect(getColumnFilterValue(genderColumn, records[0])).toBe('Male');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('reads the dataIndex value, path arrays included', () => {
|
|
45
|
+
expect(getColumnFilterValue(cityColumn, records[1])).toBe('Kochi');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('falls back to the export value when the cell has no text', () => {
|
|
49
|
+
expect(getColumnFilterValue(statusColumn, records[1])).toBe('Arrived');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('keys the filter by key, else by dataIndex', () => {
|
|
53
|
+
expect(getColumnFilterKey({ dataIndex: ['address', 'city'] })).toBe('address.city');
|
|
54
|
+
expect(getColumnFilterKey({ key: 'gender', dataIndex: 'x' })).toBe('gender');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('leaves out columns marked filterable false or without a value', () => {
|
|
58
|
+
expect(isFilterableColumn(genderColumn)).toBe(true);
|
|
59
|
+
expect(isFilterableColumn({ ...genderColumn, filterable: false })).toBe(false);
|
|
60
|
+
expect(isFilterableColumn({ key: 'empty', title: 'Empty' })).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('keeps only the records matching every filtered column', () => {
|
|
64
|
+
const columns = [genderColumn, cityColumn];
|
|
65
|
+
|
|
66
|
+
expect(applyColumnFilters(records, columns, { gender: ['Male'] }).map((r) => r.id)).toEqual([1, 3]);
|
|
67
|
+
expect(applyColumnFilters(records, columns, { gender: ['Male'], city: ['Kochi'] }).map((r) => r.id)).toEqual([3]);
|
|
68
|
+
expect(applyColumnFilters(records, columns, {})).toBe(records);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('ignores the excepted column, to list its values from the other filters', () => {
|
|
72
|
+
const columns = [genderColumn, cityColumn];
|
|
73
|
+
|
|
74
|
+
expect(applyColumnFilters(records, columns, { gender: ['Male'], city: ['Kochi'] }, 'city').map((r) => r.id)).toEqual([1, 3]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('tells whether any filter is applied', () => {
|
|
78
|
+
expect(hasColumnFilters({})).toBe(false);
|
|
79
|
+
expect(hasColumnFilters({ gender: undefined })).toBe(false);
|
|
80
|
+
expect(hasColumnFilters({ gender: ['Male'] })).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('lists distinct values sorted with blanks last', () => {
|
|
84
|
+
expect(getColumnValues(genderColumn, records)).toEqual(['Female', 'Male', '']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('adds the filter dropdown only to filterable columns', () => {
|
|
88
|
+
const columns = withColumnFilters([{ key: '#', filterable: false, render: () => 1 }, genderColumn], records, { gender: ['Male'] }, jest.fn());
|
|
89
|
+
|
|
90
|
+
expect(columns[0].filterDropdown).toBeUndefined();
|
|
91
|
+
expect(columns[1].filterDropdown).toBeInstanceOf(Function);
|
|
92
|
+
expect(columns[1].filteredValue).toEqual(['Male']);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('ColumnFilterDropdown', () => {
|
|
97
|
+
function renderDropdown(props = {}) {
|
|
98
|
+
const onApply = jest.fn();
|
|
99
|
+
const close = jest.fn();
|
|
100
|
+
|
|
101
|
+
render(<ColumnFilterDropdown column={genderColumn} records={records} visible onApply={onApply} close={close} {...props} />);
|
|
102
|
+
|
|
103
|
+
return { onApply, close };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
it('lists every value ticked when the column has no filter, blanks labelled', () => {
|
|
107
|
+
renderDropdown();
|
|
108
|
+
|
|
109
|
+
['Female', 'Male', '(Blanks)'].forEach((label) => expect(screen.getByLabelText(label)).toBeChecked());
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('applies the picked values and closes', () => {
|
|
113
|
+
const { onApply, close } = renderDropdown();
|
|
114
|
+
|
|
115
|
+
fireEvent.click(screen.getByLabelText('Female'));
|
|
116
|
+
fireEvent.click(screen.getByText('OK'));
|
|
117
|
+
|
|
118
|
+
expect(onApply).toHaveBeenCalledWith(['Male', '']);
|
|
119
|
+
expect(close).toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('removes the filter when every value is selected', () => {
|
|
123
|
+
const { onApply } = renderDropdown({ applied: ['Male'] });
|
|
124
|
+
|
|
125
|
+
fireEvent.click(screen.getByLabelText('Select All'));
|
|
126
|
+
fireEvent.click(screen.getByText('OK'));
|
|
127
|
+
|
|
128
|
+
expect(onApply).toHaveBeenCalledWith(undefined);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('Clear All unticks everything and disables OK', () => {
|
|
132
|
+
renderDropdown();
|
|
133
|
+
|
|
134
|
+
fireEvent.click(screen.getByText('Clear All'));
|
|
135
|
+
|
|
136
|
+
expect(screen.getByLabelText('Male')).not.toBeChecked();
|
|
137
|
+
expect(screen.getByText('OK').closest('button')).toBeDisabled();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('narrows the list with the search', () => {
|
|
141
|
+
renderDropdown();
|
|
142
|
+
|
|
143
|
+
fireEvent.change(screen.getByPlaceholderText('Search'), { target: { value: 'fem' } });
|
|
144
|
+
|
|
145
|
+
expect(screen.getByLabelText('Female')).toBeInTheDocument();
|
|
146
|
+
expect(screen.queryByLabelText('Male')).not.toBeInTheDocument();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -6,4 +6,53 @@ Here is how we can simply add a table.
|
|
|
6
6
|
import Table from "./table";
|
|
7
7
|
|
|
8
8
|
<Table />;
|
|
9
|
-
```
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Column filters
|
|
12
|
+
|
|
13
|
+
`columnFilters` adds an Excel style filter to every column header: the distinct values of
|
|
14
|
+
the column with Search, Select All / Clear All and a "(Blanks)" option. Filters of several
|
|
15
|
+
columns work together, and the values listed for a column are the ones left by the other
|
|
16
|
+
filters.
|
|
17
|
+
|
|
18
|
+
The values are the text shown in the cell (the column `render`), the `exportDefinition`
|
|
19
|
+
when the cell is a component, else the `dataIndex` value. Leave a column out with
|
|
20
|
+
`filterable: false`, or pass the keys of the columns to filter:
|
|
21
|
+
|
|
22
|
+
```jsx
|
|
23
|
+
<TableComponent columns={columns} dataSource={rows} columnFilters={['gender', 'source']} />
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Filtered by the table
|
|
27
|
+
|
|
28
|
+
When the whole list is in `dataSource`, the table filters it:
|
|
29
|
+
|
|
30
|
+
```jsx
|
|
31
|
+
<TableComponent columns={columns} dataSource={rows} columnFilters />
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Filtered by the screen
|
|
35
|
+
|
|
36
|
+
With server side paging the screen keeps the filters and filters the data itself:
|
|
37
|
+
|
|
38
|
+
```jsx
|
|
39
|
+
import { TableComponent, applyColumnFilters, hasColumnFilters } from 'ui-soxo-bootstrap-core';
|
|
40
|
+
|
|
41
|
+
const [filters, setFilters] = useState({});
|
|
42
|
+
|
|
43
|
+
const rows = applyColumnFilters(records, columns, filters);
|
|
44
|
+
|
|
45
|
+
<TableComponent
|
|
46
|
+
columns={columns}
|
|
47
|
+
dataSource={rows}
|
|
48
|
+
columnFilters
|
|
49
|
+
filters={filters}
|
|
50
|
+
onFilterChange={(key, values) => setFilters({ ...filters, [key]: values })}
|
|
51
|
+
// Records the values are listed from, the dataSource when not given
|
|
52
|
+
filterRecords={records}
|
|
53
|
+
/>;
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`onFilterChange` gets the column key and the selected values, `undefined` when the filter
|
|
57
|
+
is removed (every value selected). `hasColumnFilters(filters)` tells whether any filter is
|
|
58
|
+
applied, ex: to load the whole range instead of one page.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import React, { useContext, useEffect } from 'react';
|
|
1
|
+
import React, { useContext, useEffect, useState } from 'react';
|
|
2
2
|
import { Table } from 'antd';
|
|
3
3
|
import PropTypes from 'prop-types';
|
|
4
4
|
import { GlobalContext } from '../../../Store';
|
|
5
|
+
import { withColumnFilters, applyColumnFilters, getColumnFilterKey } from './column-filter/column-filter';
|
|
5
6
|
|
|
6
7
|
import './table.scss';
|
|
7
8
|
|
|
@@ -12,9 +13,55 @@ import './table.scss';
|
|
|
12
13
|
* use it for long lists only. The header cells stay inside the same table as the rows
|
|
13
14
|
* (see table.scss), so the columns always line up with the header.
|
|
14
15
|
*/
|
|
15
|
-
const TableComponent = ({
|
|
16
|
+
const TableComponent = ({
|
|
17
|
+
columns,
|
|
18
|
+
dataSource,
|
|
19
|
+
loading,
|
|
20
|
+
fixed,
|
|
21
|
+
scroll,
|
|
22
|
+
summary,
|
|
23
|
+
pagination,
|
|
24
|
+
title,
|
|
25
|
+
onChange,
|
|
26
|
+
freezeHeader,
|
|
27
|
+
// Excel style filter - true for every column (a column is left out with `filterable: false`), or the keys of the columns to filter
|
|
28
|
+
columnFilters = false,
|
|
29
|
+
// Applied filters ({ [columnKey]: values }), when the screen filters the data itself (ex: server side paging)
|
|
30
|
+
filters,
|
|
31
|
+
// Called with the column key and the selected values (undefined when the filter is removed)
|
|
32
|
+
onFilterChange,
|
|
33
|
+
// Records the filter values are listed from, the dataSource when not given
|
|
34
|
+
filterRecords,
|
|
35
|
+
}) => {
|
|
16
36
|
const { state } = useContext(GlobalContext);
|
|
17
37
|
|
|
38
|
+
const [localFilters, setLocalFilters] = useState({});
|
|
39
|
+
|
|
40
|
+
// Controlled when the screen passes the filters, else the table filters its own dataSource
|
|
41
|
+
const isControlled = filters !== undefined;
|
|
42
|
+
|
|
43
|
+
const appliedFilters = isControlled ? filters || {} : localFilters;
|
|
44
|
+
|
|
45
|
+
const filterSource = filterRecords || dataSource || [];
|
|
46
|
+
|
|
47
|
+
// With a list of keys only those columns get the filter
|
|
48
|
+
const filterColumns = Array.isArray(columnFilters)
|
|
49
|
+
? columns.map((col) => (columnFilters.includes(getColumnFilterKey(col)) ? col : { ...col, filterable: false }))
|
|
50
|
+
: columns;
|
|
51
|
+
|
|
52
|
+
const filteredColumns = columnFilters
|
|
53
|
+
? withColumnFilters(filterColumns, filterSource, appliedFilters, (key, values) => {
|
|
54
|
+
if (!isControlled) setLocalFilters((current) => ({ ...current, [key]: values }));
|
|
55
|
+
|
|
56
|
+
if (onFilterChange) onFilterChange(key, values);
|
|
57
|
+
})
|
|
58
|
+
: columns;
|
|
59
|
+
|
|
60
|
+
// The opt out flag is only for the filter, it is not passed on to antd
|
|
61
|
+
const visibleColumns = filteredColumns.map(({ filterable, ...col }) => col);
|
|
62
|
+
|
|
63
|
+
const visibleData = columnFilters && !isControlled ? applyColumnFilters(dataSource, filterColumns, localFilters) : dataSource;
|
|
64
|
+
|
|
18
65
|
useEffect(() => {}, [state.theme.colors]);
|
|
19
66
|
|
|
20
67
|
// Header styling
|
|
@@ -57,7 +104,7 @@ const TableComponent = ({ columns, dataSource, loading, fixed, scroll, summary,
|
|
|
57
104
|
}));
|
|
58
105
|
|
|
59
106
|
// Apply fixed property to the columns you need
|
|
60
|
-
const updatedColumns =
|
|
107
|
+
const updatedColumns = visibleColumns.map((col) => ({
|
|
61
108
|
...col,
|
|
62
109
|
onHeaderCell: () => ({
|
|
63
110
|
style: headerStyle,
|
|
@@ -70,7 +117,7 @@ const TableComponent = ({ columns, dataSource, loading, fixed, scroll, summary,
|
|
|
70
117
|
title={title ? () => title : undefined}
|
|
71
118
|
scroll={scroll}
|
|
72
119
|
className={freezeHeader ? 'frozen-header-table' : undefined}
|
|
73
|
-
dataSource={
|
|
120
|
+
dataSource={visibleData}
|
|
74
121
|
loading={loading}
|
|
75
122
|
bordered
|
|
76
123
|
fixed={fixed}
|
|
@@ -97,6 +144,14 @@ TableComponent.propTypes = {
|
|
|
97
144
|
data: PropTypes.array.isRequired,
|
|
98
145
|
loading: PropTypes.bool,
|
|
99
146
|
freezeHeader: PropTypes.bool,
|
|
147
|
+
/** Excel style filter - true for every column (a column is left out with `filterable: false`), or the keys of the columns to filter */
|
|
148
|
+
columnFilters: PropTypes.oneOfType([PropTypes.bool, PropTypes.arrayOf(PropTypes.string)]),
|
|
149
|
+
/** Applied filters ({ [columnKey]: values }), pass it to filter the data in the screen */
|
|
150
|
+
filters: PropTypes.object,
|
|
151
|
+
/** Called with the column key and the selected values, undefined when the filter is removed */
|
|
152
|
+
onFilterChange: PropTypes.func,
|
|
153
|
+
/** Records the filter values are listed from, the dataSource when not given */
|
|
154
|
+
filterRecords: PropTypes.array,
|
|
100
155
|
};
|
|
101
156
|
|
|
102
157
|
TableComponent.defaultProps = {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import TableComponent from './table';
|
|
5
|
+
import { GlobalContext } from '../../../Store';
|
|
6
|
+
|
|
7
|
+
jest.mock('react-i18next', () => ({
|
|
8
|
+
useTranslation: () => ({ t: (text) => text }),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
// The real store pulls in every module of the library, the table only reads the theme from it
|
|
12
|
+
jest.mock('../../../Store', () => ({ GlobalContext: require('react').createContext({}) }));
|
|
13
|
+
|
|
14
|
+
// The core Button reads the theme from the global store, a plain button is enough here
|
|
15
|
+
jest.mock('../button/button', () => ({ children, onClick, disabled }) => (
|
|
16
|
+
<button type="button" onClick={onClick} disabled={disabled}>
|
|
17
|
+
{children}
|
|
18
|
+
</button>
|
|
19
|
+
));
|
|
20
|
+
|
|
21
|
+
// antd Table watches the screen size, jsdom has no matchMedia
|
|
22
|
+
beforeAll(() => {
|
|
23
|
+
window.matchMedia =
|
|
24
|
+
window.matchMedia ||
|
|
25
|
+
(() => ({ matches: false, addListener: () => {}, removeListener: () => {}, addEventListener: () => {}, removeEventListener: () => {} }));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const columns = [
|
|
29
|
+
{ key: 'name', title: 'Name', render: (record) => record.name },
|
|
30
|
+
{ key: 'gender', title: 'Gender', render: (record) => record.gender },
|
|
31
|
+
{ key: 'city', title: 'City', dataIndex: 'city' },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const rows = [
|
|
35
|
+
{ key: 1, name: 'Anu', gender: 'Female', city: 'Kochi' },
|
|
36
|
+
{ key: 2, name: 'Ravi', gender: 'Male', city: 'Calicut' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function renderTable(props) {
|
|
40
|
+
return render(
|
|
41
|
+
<GlobalContext.Provider value={{ state: { theme: { colors: {} } } }}>
|
|
42
|
+
<TableComponent columns={columns} dataSource={rows} {...props} />
|
|
43
|
+
</GlobalContext.Provider>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('TableComponent column filters', () => {
|
|
48
|
+
it('has no filter without columnFilters', () => {
|
|
49
|
+
const { container } = renderTable();
|
|
50
|
+
|
|
51
|
+
expect(container.querySelectorAll('.column-filter-icon')).toHaveLength(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('filters every column with columnFilters', () => {
|
|
55
|
+
const { container } = renderTable({ columnFilters: true });
|
|
56
|
+
|
|
57
|
+
expect(container.querySelectorAll('.column-filter-icon')).toHaveLength(3);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('filters only the listed columns', () => {
|
|
61
|
+
const { container } = renderTable({ columnFilters: ['gender'] });
|
|
62
|
+
|
|
63
|
+
expect(container.querySelectorAll('.column-filter-icon')).toHaveLength(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('filters its own rows with the applied filters', () => {
|
|
67
|
+
const { getByText, queryByText } = renderTable({ columnFilters: ['gender'], filters: undefined });
|
|
68
|
+
|
|
69
|
+
expect(getByText('Anu')).toBeTruthy();
|
|
70
|
+
expect(queryByText('Ravi')).toBeTruthy();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('shows the screen filters as active, leaving the rows to the screen', () => {
|
|
74
|
+
const { container, getByText } = renderTable({ columnFilters: ['gender'], filters: { gender: ['Male'] }, onFilterChange: jest.fn() });
|
|
75
|
+
|
|
76
|
+
expect(container.querySelectorAll('.column-filter-icon--active')).toHaveLength(1);
|
|
77
|
+
// Controlled: the screen filters the dataSource, the table shows what it gets
|
|
78
|
+
expect(getByText('Anu')).toBeTruthy();
|
|
79
|
+
});
|
|
80
|
+
});
|