genesys-react-components 0.4.0 → 0.4.1-devengage-1573-implementing-tables.246

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,431 @@
1
+ /* eslint-disable react-hooks/exhaustive-deps */
2
+ import { GenesysDevIcon, GenesysDevIcons } from 'genesys-dev-icons';
3
+ import DxTextbox from '../dxtextbox/DxTextbox';
4
+ import DxToggle from '../dxtoggle/DxToggle';
5
+ import moment from 'moment';
6
+ import React, { useEffect, useState, ReactNode } from 'react';
7
+ import CopyButton from '../copybutton/CopyButton';
8
+ import { DataTableRow } from '..';
9
+ import './DataTable.scss';
10
+
11
+ interface IProps {
12
+ rows: DataTableRow[];
13
+ headerRow?: DataTableRow;
14
+ className?: string;
15
+ indentation?: number;
16
+ sortable?: boolean;
17
+ filterable?: boolean;
18
+ }
19
+
20
+ interface ColumnFilterCollection {
21
+ [colId: string]: ColumnFilter;
22
+ }
23
+
24
+ interface ColumnFilter {
25
+ colId: number;
26
+ dataType: 'string' | 'number' | 'datetime';
27
+ filter: any;
28
+ filterModifier?: FilterModifierGtLt;
29
+ }
30
+
31
+ type FilterModifierGtLt = 'greaterthan' | 'lessthan';
32
+
33
+ interface ColumnSort {
34
+ colId?: number;
35
+ sort: 'none' | 'ascending' | 'descending';
36
+ }
37
+
38
+ interface ColumnTypeCollection {
39
+ [colId: string]: 'string' | 'number' | 'date';
40
+ }
41
+
42
+ interface RawColumnTypeCollection {
43
+ [colId: string]: RawColumnTypeCount;
44
+ }
45
+
46
+ interface RawColumnTypeCount {
47
+ colId: number;
48
+ number: number;
49
+ date: number;
50
+ string: number;
51
+ }
52
+
53
+ const TABLE_CLASS_REGEX = /(?:^|\s)table(?:$|\s)/i;
54
+
55
+ export default function DataTable(props: IProps) {
56
+ // filterRows filters the input rows using the configured filters
57
+ const filterRows = (): DataTableRow[] => {
58
+ // Return raw data if we don't have info to filter
59
+ if (!columnTypes || Object.keys(columnTypes).length === 0 || !filters || Object.keys(filters).length === 0) return parsedRows;
60
+
61
+ // Filter source rows
62
+ let newRows = [] as DataTableRow[];
63
+ let anyValidFilters = false;
64
+ parsedRows.forEach((row) => {
65
+ let filterMatch: boolean | undefined;
66
+ Object.keys(filters)
67
+ .map((i) => {
68
+ let ii = parseInt(i);
69
+ return ii;
70
+ })
71
+ // .map(parseInt)
72
+ .forEach((colId) => {
73
+ const filter = filters[colId];
74
+ if (!filter || filter.filter === '' || filter.filter === undefined) return;
75
+ switch (filter.dataType) {
76
+ case 'datetime': {
77
+ const m = filter.filter as moment.Moment | undefined;
78
+ const value = row.cells[colId].parsedContent as Date | undefined;
79
+ if (filterMatch === false || !moment.isMoment(m) || !m.isValid() || !value) return;
80
+ const datePoint = m.toDate();
81
+ filterMatch = filter.filterModifier === 'greaterthan' ? value > datePoint : value < datePoint;
82
+ break;
83
+ }
84
+ case 'number': {
85
+ if (filter.filter === '' || filter.filter === undefined || !filter.filterModifier || filterMatch === false) return;
86
+ if (filter.filterModifier === 'greaterthan' && (row.cells[colId].parsedContent as number) >= filter.filter)
87
+ filterMatch = true;
88
+ else if (filter.filterModifier === 'lessthan' && (row.cells[colId].parsedContent as number) <= filter.filter)
89
+ filterMatch = true;
90
+ else if (filter.filterModifier !== undefined) filterMatch = false;
91
+ // Didn't hit a valid filter, take no action
92
+ if (filterMatch === undefined) return;
93
+ break;
94
+ }
95
+ case 'string':
96
+ default: {
97
+ if (filter.filter === '' || filterMatch === false) return;
98
+ filterMatch = (row.cells[colId].parsedContent as string).includes(filter.filter);
99
+ }
100
+ }
101
+ anyValidFilters = true;
102
+ });
103
+ if (filterMatch === true) newRows.push(row);
104
+ });
105
+
106
+ return anyValidFilters ? newRows : parsedRows;
107
+ };
108
+
109
+ // sortRows sorts the filtered rows using the configured sorting
110
+ const sortRows = (): DataTableRow[] => {
111
+ // Abort if we can't sort
112
+ if (!colsort || colsort.colId === undefined || filteredRows.length < 2 || !filteredRows[0].cells[colsort.colId]) return filteredRows;
113
+
114
+ // Unsort rows
115
+ if (colsort.sort === 'none') {
116
+ return filteredRows;
117
+ }
118
+
119
+ // Sort rows
120
+ const i = colsort.colId;
121
+ const isAscending = colsort.sort === 'ascending';
122
+ return [...filteredRows].sort((a, b) => {
123
+ if ((a.cells[i].parsedContent as number) < (b.cells[i].parsedContent as number)) return isAscending ? -1 : 1;
124
+ if ((a.cells[i].parsedContent as number) > (b.cells[i].parsedContent as number)) return isAscending ? 1 : -1;
125
+ return 0;
126
+ });
127
+ };
128
+
129
+ const [parsedRows, setParsedRows] = useState([] as DataTableRow[]);
130
+ // Filtered set of rows (first pass)
131
+ const [filteredRows, setFilteredRows] = useState([] as DataTableRow[]);
132
+ // Sorted set of rows (second pass)
133
+ const [sortedRows, setSortedRows] = useState([] as DataTableRow[]);
134
+ // Rows to display in the table (third pass, paginated)
135
+ const [rows, setRows] = useState([] as DataTableRow[]);
136
+
137
+ const [filters, setFilters] = useState({} as ColumnFilterCollection);
138
+ const [colsort, setColsort] = useState({ sort: 'none' } as ColumnSort);
139
+
140
+ const [columnTypes, setColumnTypes] = useState({} as ColumnTypeCollection);
141
+ const [isFilterOpen, setIsFilterOpen] = useState(false);
142
+
143
+ // "Constructor"
144
+ useEffect(() => {
145
+ // Infer column types
146
+ if (props.rows.length > 0 && props.rows[0].cells.length > 0) {
147
+ // Seed columns
148
+ const cellTypeData = {} as RawColumnTypeCollection;
149
+ props.rows[0].cells.forEach((cell, i) => (cellTypeData[i] = { colId: i, number: 0, date: 0, string: 0 }));
150
+ // Iterate rows and cells to infer and count types
151
+ props.rows.forEach((row) => {
152
+ row.cells.forEach((cell, i) => {
153
+ if (!cell || !cell.content || !cellTypeData[i]) return;
154
+
155
+ // Check number first (moment parses numbers as dates successfully)
156
+ // Passing a string to isNaN uses built-in type coersion logic that's different than Number.parseFloat()
157
+ if (!isNaN(cell.content as any) && !isNaN(parseFloat(cell.content))) {
158
+ cellTypeData[i].number++;
159
+ cell.parsedContent = parseFloat(cell.content);
160
+ return;
161
+ }
162
+
163
+ // Check date
164
+ if (moment(cell.content, 'M/D/YYYY', true).isValid() || moment(cell.content, 'M-D-YYYY', true).isValid()) {
165
+ cellTypeData[i].date++;
166
+ cell.parsedContent = Date.parse(cell.content);
167
+ return;
168
+ }
169
+
170
+ // Default: string
171
+ cellTypeData[i].string++;
172
+ cell.parsedContent = cell.content.toLowerCase();
173
+ });
174
+ });
175
+
176
+ // Assign column types
177
+ const newColumnTypes = {} as ColumnTypeCollection;
178
+ for (let i = 0; i < props.rows[0].cells.length; i++) {
179
+ const maxCount = Math.max(cellTypeData[i].date, cellTypeData[i].number, cellTypeData[i].string);
180
+ if (cellTypeData[i].date === maxCount) newColumnTypes[i] = 'date';
181
+ else if (cellTypeData[i].number === maxCount) newColumnTypes[i] = 'number';
182
+ else newColumnTypes[i] = 'string';
183
+ }
184
+ setColumnTypes(newColumnTypes);
185
+ setParsedRows(props.rows);
186
+ }
187
+ }, []);
188
+
189
+ // Filter changed
190
+ useEffect(() => {
191
+ const r = filterRows();
192
+ setFilteredRows(r);
193
+ }, [filters, columnTypes, parsedRows]);
194
+
195
+ // Sort or filtered rows (source) changed
196
+ useEffect(() => {
197
+ const r = sortRows();
198
+ setSortedRows(r);
199
+ }, [colsort, filteredRows]);
200
+
201
+ // sorted rows (source) changed
202
+ useEffect(() => {
203
+ setRows([...sortedRows]);
204
+ }, [sortedRows]);
205
+
206
+ // Consolidation props to identify conditions for rendering
207
+ const isSortable = props.sortable || props.className?.includes('sortable') || props.className?.includes('sort-and-filter');
208
+ const isFilterable = props.filterable || props.className?.includes('filterable') || props.className?.includes('sort-and-filter');
209
+
210
+ // getSortCaret returns the FontAwesome glyph name to use for the column sort indicator based on the current sort configuration
211
+ const getSortCaret = (columnId: number): GenesysDevIcons => {
212
+ if (colsort.colId !== columnId || colsort.sort === 'none') return GenesysDevIcons.AppSort;
213
+ return colsort.sort === 'descending' ? GenesysDevIcons.AppSortDown : GenesysDevIcons.AppSortUp;
214
+ };
215
+
216
+ // The filterChanged functions are raised when the user updates a filter column
217
+ const stringFilterChanged = (colId: string, filterValue: string) => {
218
+ const newFilters = { ...filters };
219
+ newFilters[colId] = { colId: parseInt(colId), dataType: 'string', filter: filterValue.toLowerCase() };
220
+ setFilters(newFilters);
221
+ };
222
+ const numberFilterChanged = (colId: string, filterValue: string) => {
223
+ const newFilters = { ...filters };
224
+ const i = parseFloat(filterValue);
225
+ newFilters[colId] = { colId: parseInt(colId), dataType: 'number', filter: isNaN(i) ? undefined : i, filterModifier: 'lessthan' };
226
+ if (filters[colId]) newFilters[colId].filterModifier = filters[colId].filterModifier;
227
+ setFilters(newFilters);
228
+ };
229
+ const numberFilterModifierChanged = (colId: string, filterModifier: FilterModifierGtLt) => {
230
+ const newFilters = { ...filters };
231
+ if (!newFilters[colId]) newFilters[colId] = { colId: parseInt(colId), dataType: 'number', filter: undefined };
232
+ newFilters[colId].filterModifier = filterModifier;
233
+ setFilters(newFilters);
234
+ };
235
+ const dateFilterChanged = (colId: string, filterValue: string) => {
236
+ const newFilters = { ...filters };
237
+ newFilters[colId] = { colId: parseInt(colId), dataType: 'datetime', filter: moment(filterValue), filterModifier: 'lessthan' };
238
+ if (filters[colId]) newFilters[colId].filterModifier = filters[colId].filterModifier;
239
+ setFilters(newFilters);
240
+ };
241
+ const dateFilterModifierChanged = (colId: string, filterModifier: FilterModifierGtLt) => {
242
+ const newFilters = { ...filters };
243
+ if (!newFilters[colId]) newFilters[colId] = { colId: parseInt(colId), dataType: 'datetime', filter: undefined };
244
+ newFilters[colId].filterModifier = filterModifier;
245
+ setFilters(newFilters);
246
+ };
247
+
248
+ // sortChanged is raised when the user clicks a sortable column header
249
+ const sortChanged = (columnId: string) => {
250
+ const colId = parseInt(columnId);
251
+ const newSort = { ...colsort };
252
+ newSort.colId = colId;
253
+ // Unset column on invalid id
254
+ if (colId < 0 || (rows[0] && colId >= rows[0].cells.length)) newSort.colId = undefined;
255
+
256
+ // Update sort order
257
+ if (newSort.colId !== colsort.colId) {
258
+ // New sorts are always descending first
259
+ newSort.sort = 'ascending';
260
+ } else {
261
+ // Rotate sort order
262
+ switch (newSort.sort) {
263
+ case 'ascending': {
264
+ newSort.sort = 'descending';
265
+ break;
266
+ }
267
+ case 'descending': {
268
+ newSort.sort = 'none';
269
+ break;
270
+ }
271
+ default: {
272
+ newSort.sort = 'ascending';
273
+ }
274
+ }
275
+ }
276
+
277
+ setColsort(newSort);
278
+ };
279
+
280
+ /***** Setup complete, build the component *****/
281
+
282
+ // Build column headers
283
+ let columnHeaders;
284
+ if (props.headerRow) {
285
+ columnHeaders = (
286
+ <tr>
287
+ {props.headerRow.cells.map((cell, i) => (
288
+ <td
289
+ key={i}
290
+ align={cell?.align || 'left'}
291
+ className={colsort.colId === i && colsort.sort !== 'none' ? '' : 'unsorted'}
292
+ onClick={isSortable ? () => sortChanged(i.toString()) : undefined}
293
+ >
294
+ <div className={`header-container align-${cell?.align || 'left'}`}>
295
+ {cell.renderedContent || cell.content}
296
+ {filters[i] && filters[i].filter !== '' && filters[i].filter !== undefined ? (
297
+ <GenesysDevIcon icon={GenesysDevIcons.AppFilter} className="filter-active-icon" />
298
+ ) : (
299
+ ''
300
+ )}
301
+ {isSortable ? <GenesysDevIcon icon={getSortCaret(i)} className="sort-icon" /> : null}
302
+ </div>
303
+ </td>
304
+ ))}
305
+ </tr>
306
+ );
307
+ }
308
+
309
+ // Build filter row
310
+ let filterRow;
311
+ if (isFilterable && Object.keys(columnTypes).length > 0) {
312
+ filterRow = (
313
+ <React.Fragment>
314
+ <tr className="filter-spacer"></tr>
315
+ <tr className="filter-row">
316
+ {Object.keys(columnTypes).map((colId, i) => {
317
+ const columnType = columnTypes[colId];
318
+ switch (columnType) {
319
+ case 'date': {
320
+ return (
321
+ <td key={colId}>
322
+ <div className="sort-date">
323
+ <DxTextbox
324
+ className="date-filter"
325
+ label="Filter date"
326
+ inputType="date"
327
+ onChange={(value) => dateFilterChanged(colId, value)}
328
+ initialValue={moment.isMoment(filters[i]?.filter) ? filters[i]?.filter.format('YYYY-MM-DD') : undefined}
329
+ />
330
+ <DxToggle
331
+ label="Comparison"
332
+ falseIcon={GenesysDevIcons.AppChevronLeft}
333
+ trueIcon={GenesysDevIcons.AppChevronRight}
334
+ initialValue={filters[i]?.filterModifier === 'greaterthan'}
335
+ onChange={(value) => dateFilterModifierChanged(colId, value === false ? 'lessthan' : 'greaterthan')}
336
+ />
337
+ </div>
338
+ </td>
339
+ );
340
+ }
341
+ case 'number': {
342
+ return (
343
+ <td key={colId}>
344
+ <div className="sort-numeric">
345
+ <DxTextbox
346
+ label="Value"
347
+ inputType="decimal"
348
+ onChange={(value) => numberFilterChanged(colId, value)}
349
+ placeholder={props.headerRow?.cells[i]?.content}
350
+ initialValue={filters[i]?.filter}
351
+ />
352
+ <DxToggle
353
+ label="Comparison"
354
+ falseIcon={GenesysDevIcons.AppChevronLeft}
355
+ trueIcon={GenesysDevIcons.AppChevronRight}
356
+ initialValue={filters[i]?.filterModifier === 'greaterthan'}
357
+ onChange={(value) => numberFilterModifierChanged(colId, value === false ? 'lessthan' : 'greaterthan')}
358
+ />
359
+ </div>
360
+ </td>
361
+ );
362
+ }
363
+ case 'string':
364
+ default: {
365
+ return (
366
+ <td key={colId}>
367
+ <DxTextbox
368
+ label="Filter text"
369
+ inputType="text"
370
+ icon={GenesysDevIcons.AppSearch}
371
+ placeholder={props.headerRow?.cells[i]?.content}
372
+ onChange={(value) => stringFilterChanged(colId, value)}
373
+ clearButton={true}
374
+ initialValue={filters[i]?.filter}
375
+ />
376
+ </td>
377
+ );
378
+ }
379
+ }
380
+ })}
381
+ </tr>
382
+ </React.Fragment>
383
+ );
384
+ }
385
+
386
+ // Build optional table header
387
+ let thead;
388
+ if (columnHeaders || filterRow) {
389
+ thead = (
390
+ <thead>
391
+ {columnHeaders}
392
+ {isFilterOpen ? filterRow : undefined}
393
+ </thead>
394
+ );
395
+ }
396
+
397
+ // Make sure classes always has "table"; sometimes it will be provided, sometimes not
398
+ let tableClassName = props.className || '';
399
+ if (tableClassName?.match(TABLE_CLASS_REGEX) === null) {
400
+ tableClassName = 'table ' + tableClassName.trim();
401
+ }
402
+
403
+ return (
404
+ <div className={`table-container${isSortable ? ' sortable' : ''}${isFilterable ? ' filterable' : ''}`}>
405
+ <div className="filter-container">
406
+ <div className="filter-toggle" style={{ visibility: isFilterable ? 'visible' : 'hidden' }}>
407
+ <GenesysDevIcon icon={GenesysDevIcons.AppFilter} onClick={() => setIsFilterOpen(!isFilterOpen)} />
408
+ </div>
409
+ <table className={tableClassName} cellSpacing="0">
410
+ {thead}
411
+ <tbody>
412
+ {rows.map((row, i) => (
413
+ <tr key={i}>
414
+ {row.cells.map((cell, ii) => (
415
+ <td key={ii} align={cell?.align || 'left'}>
416
+ {cell?.content ? (
417
+ <div className={`align-${cell?.align || 'left'}`}>
418
+ {cell.renderedContent || cell.content}
419
+ {cell.copyButton ? <CopyButton copyText={cell.content} /> : undefined}
420
+ </div>
421
+ ) : null}
422
+ </td>
423
+ ))}
424
+ </tr>
425
+ ))}
426
+ </tbody>
427
+ </table>
428
+ </div>
429
+ </div>
430
+ );
431
+ }
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import AlertBlock from './alertblock/AlertBlock';
13
13
  import LoadingPlaceholder from './loadingplaceholder/LoadingPlaceholder';
14
14
  import Tooltip from './tooltip/Tooltip';
15
15
  import CopyButton from './copybutton/CopyButton';
16
+ import DataTable from './datatable/DataTable';
16
17
  import CodeFence from './codefence/CodeFence';
17
18
 
18
19
  export {
@@ -31,6 +32,7 @@ export {
31
32
  LoadingPlaceholder,
32
33
  AlertBlock,
33
34
  CodeFence,
35
+ DataTable,
34
36
  };
35
37
 
36
38
  export interface StringChangedCallback {
@@ -142,3 +144,15 @@ export interface DxTabPanelProps {
142
144
  children: React.ReactNode;
143
145
  className?: string;
144
146
  }
147
+
148
+ export interface DataTableRow {
149
+ cells: DataTableCell[];
150
+ }
151
+
152
+ export interface DataTableCell {
153
+ renderedContent?: React.ReactNode;
154
+ content: string;
155
+ parsedContent?: string | number | Date;
156
+ align?: 'left' | 'center' | 'right';
157
+ copyButton?: boolean;
158
+ }