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.
@@ -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 = ({ columns, dataSource, loading, fixed, scroll, summary, pagination, title, onChange, freezeHeader }) => {
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 = columns.map((col) => ({
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={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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ui-soxo-bootstrap-core",
3
- "version": "2.6.62-dev.4",
3
+ "version": "2.6.63-dev.0",
4
4
  "description": "All the Core Components for you to start",
5
5
  "keywords": [
6
6
  "all in one"