ui-soxo-bootstrap-core 2.6.62-dev.4 → 2.6.63-dev.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.
@@ -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.1",
4
4
  "description": "All the Core Components for you to start",
5
5
  "keywords": [
6
6
  "all in one"