Add aria-labels
[ccsdk/features.git] / sdnr / wt / odlux / framework / src / components / material-table / index.tsx
1 /**
2  * ============LICENSE_START========================================================================
3  * ONAP : ccsdk feature sdnr wt odlux
4  * =================================================================================================
5  * Copyright (C) 2019 highstreet technologies GmbH Intellectual Property. All rights reserved.
6  * =================================================================================================
7  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
8  * in compliance with the License. You may obtain a copy of the License at
9  *
10  * http://www.apache.org/licenses/LICENSE-2.0
11  *
12  * Unless required by applicable law or agreed to in writing, software distributed under the License
13  * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
14  * or implied. See the License for the specific language governing permissions and limitations under
15  * the License.
16  * ============LICENSE_END==========================================================================
17  */
18 import * as React from 'react';
19 import { withStyles, WithStyles, createStyles, Theme } from '@material-ui/core/styles';
20
21 import Table from '@material-ui/core/Table';
22 import TableBody from '@material-ui/core/TableBody';
23 import TableCell from '@material-ui/core/TableCell';
24 import TableContainer from '@material-ui/core/TableContainer';
25 import TablePagination from '@material-ui/core/TablePagination';
26 import TableRow from '@material-ui/core/TableRow';
27 import Paper from '@material-ui/core/Paper';
28 import Checkbox from '@material-ui/core/Checkbox';
29
30 import { TableToolbar } from './tableToolbar';
31 import { EnhancedTableHead } from './tableHead';
32 import { EnhancedTableFilter } from './tableFilter';
33
34 import { ColumnModel, ColumnType } from './columnModel';
35 import { Omit, Menu } from '@material-ui/core';
36
37 import { SvgIconProps } from '@material-ui/core/SvgIcon/SvgIcon';
38
39 import { DividerTypeMap } from '@material-ui/core/Divider';
40 import { MenuItemProps } from '@material-ui/core/MenuItem';
41 import { flexbox } from '@material-ui/system';
42 export { ColumnModel, ColumnType } from './columnModel';
43
44 type propType = string | number | null | undefined | (string | number)[];
45 type dataType = { [prop: string]: propType };
46 type resultType<TData = dataType> = { page: number, total: number, rows: TData[] };
47
48 export type DataCallback<TData = dataType> = (page?: number, rowsPerPage?: number, orderBy?: string | null, order?: 'asc' | 'desc' | null, filter?: { [property: string]: string }) => resultType<TData> | Promise<resultType<TData>>;
49
50 function desc(a: dataType, b: dataType, orderBy: string) {
51   if ((b[orderBy] || "") < (a[orderBy] || "")) {
52     return -1;
53   }
54   if ((b[orderBy] || "") > (a[orderBy] || "")) {
55     return 1;
56   }
57   return 0;
58 }
59
60 function stableSort(array: dataType[], cmp: (a: dataType, b: dataType) => number) {
61   const stabilizedThis = array.map((el, index) => [el, index]) as [dataType, number][];
62   stabilizedThis.sort((a, b) => {
63     const order = cmp(a[0], b[0]);
64     if (order !== 0) return order;
65     return a[1] - b[1];
66   });
67   return stabilizedThis.map(el => el[0]);
68 }
69
70 function getSorting(order: 'asc' | 'desc' | null, orderBy: string) {
71   return order === 'desc' ? (a: dataType, b: dataType) => desc(a, b, orderBy) : (a: dataType, b: dataType) => -desc(a, b, orderBy);
72 }
73
74 const styles = (theme: Theme) => createStyles({
75   root: {
76     width: '100%',
77     overflow: "hidden",
78     marginTop: theme.spacing(3),
79     position: "relative",
80     boxSizing: "border-box",
81     display: "flex",
82     flexDirection: "column",
83   },
84   container: {
85     flex: "1 1 100%"
86   },
87   pagination: {
88     overflow: "hidden"
89   }
90 });
91
92 export type MaterialTableComponentState<TData = {}> = {
93   order: 'asc' | 'desc';
94   orderBy: string | null;
95   selected: any[] | null;
96   rows: TData[];
97   total: number;
98   page: number;
99   rowsPerPage: number;
100   loading: boolean;
101   showFilter: boolean;
102   filter: { [property: string]: string };
103 };
104
105 export type TableApi = { forceRefresh?: () => Promise<void> };
106
107 type MaterialTableComponentBaseProps<TData> = WithStyles<typeof styles> & {
108   className?: string;
109   columns: ColumnModel<TData>[];
110   idProperty: keyof TData | ((data: TData) => React.Key);
111   tableId?: string;
112   title?: string;
113   stickyHeader?: boolean;
114   defaultSortOrder?: 'asc' | 'desc';
115   defaultSortColumn?: keyof TData;
116   enableSelection?: boolean;
117   disableSorting?: boolean;
118   disableFilter?: boolean;
119   customActionButtons?: { icon: React.ComponentType<SvgIconProps>, tooltip?: string, onClick: () => void }[];
120   onHandleClick?(event: React.MouseEvent<HTMLTableRowElement>, rowData: TData): void;
121   createContextMenu?: (row: TData) => React.ReactElement<MenuItemProps | DividerTypeMap<{}, "hr">, React.ComponentType<MenuItemProps | DividerTypeMap<{}, "hr">>>[];
122 };
123
124 type MaterialTableComponentPropsWithRows<TData = {}> = MaterialTableComponentBaseProps<TData> & { rows: TData[]; asynchronus?: boolean; };
125 type MaterialTableComponentPropsWithRequestData<TData = {}> = MaterialTableComponentBaseProps<TData> & { onRequestData: DataCallback; tableApi?: TableApi; };
126 type MaterialTableComponentPropsWithExternalState<TData = {}> = MaterialTableComponentBaseProps<TData> & MaterialTableComponentState & {
127   onToggleFilter: () => void;
128   onFilterChanged: (property: string, filterTerm: string) => void;
129   onHandleChangePage: (page: number) => void;
130   onHandleChangeRowsPerPage: (rowsPerPage: number | null) => void;
131   onHandleRequestSort: (property: string) => void;
132 };
133
134 type MaterialTableComponentProps<TData = {}> =
135   MaterialTableComponentPropsWithRows<TData> |
136   MaterialTableComponentPropsWithRequestData<TData> |
137   MaterialTableComponentPropsWithExternalState<TData>;
138
139 function isMaterialTableComponentPropsWithRows(props: MaterialTableComponentProps): props is MaterialTableComponentPropsWithRows {
140   return (props as MaterialTableComponentPropsWithRows).rows !== undefined && (props as MaterialTableComponentPropsWithRows).rows instanceof Array;
141 }
142
143 function isMaterialTableComponentPropsWithRequestData(props: MaterialTableComponentProps): props is MaterialTableComponentPropsWithRequestData {
144   return (props as MaterialTableComponentPropsWithRequestData).onRequestData !== undefined && (props as MaterialTableComponentPropsWithRequestData).onRequestData instanceof Function;
145 }
146
147 function isMaterialTableComponentPropsWithRowsAndRequestData(props: MaterialTableComponentProps): props is MaterialTableComponentPropsWithExternalState {
148   const propsWithExternalState = (props as MaterialTableComponentPropsWithExternalState)
149   return propsWithExternalState.onFilterChanged instanceof Function ||
150     propsWithExternalState.onHandleChangePage instanceof Function ||
151     propsWithExternalState.onHandleChangeRowsPerPage instanceof Function ||
152     propsWithExternalState.onToggleFilter instanceof Function ||
153     propsWithExternalState.onHandleRequestSort instanceof Function
154 }
155
156 class MaterialTableComponent<TData extends {} = {}> extends React.Component<MaterialTableComponentProps, MaterialTableComponentState & { contextMenuInfo: { index: number; mouseX?: number; mouseY?: number }; }> {
157
158   constructor(props: MaterialTableComponentProps) {
159     super(props);
160
161     const page = isMaterialTableComponentPropsWithRowsAndRequestData(this.props) ? this.props.page : 0;
162     const rowsPerPage = isMaterialTableComponentPropsWithRowsAndRequestData(this.props) ? this.props.rowsPerPage || 10 : 10;
163
164     this.state = {
165       contextMenuInfo: { index: -1 },
166       filter: isMaterialTableComponentPropsWithRowsAndRequestData(this.props) ? this.props.filter || {} : {},
167       showFilter: isMaterialTableComponentPropsWithRowsAndRequestData(this.props) ? this.props.showFilter : false,
168       loading: isMaterialTableComponentPropsWithRowsAndRequestData(this.props) ? this.props.loading : false,
169       order: isMaterialTableComponentPropsWithRowsAndRequestData(this.props) ? this.props.order : this.props.defaultSortOrder || 'asc',
170       orderBy: isMaterialTableComponentPropsWithRowsAndRequestData(this.props) ? this.props.orderBy : this.props.defaultSortColumn || null,
171       selected: isMaterialTableComponentPropsWithRowsAndRequestData(this.props) ? this.props.selected : null,
172       rows: isMaterialTableComponentPropsWithRows(this.props) && this.props.rows.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) || [],
173       total: isMaterialTableComponentPropsWithRows(this.props) && this.props.rows.length || 0,
174       page,
175       rowsPerPage,
176     };
177
178     if (isMaterialTableComponentPropsWithRequestData(this.props)) {
179       this.update();
180
181       if (this.props.tableApi) {
182         this.props.tableApi.forceRefresh = () => this.update();
183       }
184     }
185   }
186   render(): JSX.Element {
187     const { classes, columns } = this.props;
188     const { rows, total: rowCount, order, orderBy, selected, rowsPerPage, page, showFilter, filter } = this.state;
189     const emptyRows = rowsPerPage - Math.min(rowsPerPage, rowCount - page * rowsPerPage);
190     const getId = typeof this.props.idProperty !== "function" ? (data: TData) => ((data as { [key: string]: any })[this.props.idProperty as any as string] as string | number) : this.props.idProperty;
191     const toggleFilter = isMaterialTableComponentPropsWithRowsAndRequestData(this.props) ? this.props.onToggleFilter : () => { !this.props.disableFilter && this.setState({ showFilter: !showFilter }, this.update) }
192     return (
193       <Paper className={this.props.className ? `${classes.root} ${this.props.className}` : classes.root}>
194         <TableContainer className={classes.container}>
195           <TableToolbar tableId={this.props.tableId} numSelected={selected && selected.length} title={this.props.title} customActionButtons={this.props.customActionButtons} onExportToCsv={this.exportToCsv}
196             onToggleFilter={toggleFilter} />
197           <Table aria-label={this.props.tableId ? this.props.tableId : 'tableTitle'} stickyHeader={this.props.stickyHeader || false} >
198             <EnhancedTableHead
199               columns={columns}
200               numSelected={selected && selected.length}
201               order={order}
202               orderBy={orderBy}
203               onSelectAllClick={this.handleSelectAllClick}
204               onRequestSort={this.onHandleRequestSort}
205               rowCount={rows.length}
206               enableSelection={this.props.enableSelection}
207             />
208             <TableBody>
209               {showFilter && <EnhancedTableFilter columns={columns} filter={filter} onFilterChanged={this.onFilterChanged} enableSelection={this.props.enableSelection} /> || null}
210               {rows // may need ordering here
211                 .map((entry: TData & { [key: string]: any }, index) => {
212                   const entryId = getId(entry);
213                   const isSelected = this.isSelected(entryId);
214                   const contextMenu = (this.props.createContextMenu && this.state.contextMenuInfo.index === index && this.props.createContextMenu(entry)) || null;
215                   return (
216                     <TableRow
217                       hover
218                       onClick={event => {
219                         if (this.props.createContextMenu) {
220                           this.setState({
221                             contextMenuInfo: {
222                               index: -1
223                             }
224                           });
225                         }
226                         this.handleClick(event, entry, entryId);
227                       }}
228                       onContextMenu={event => {
229                         if (this.props.createContextMenu) {
230                           event.preventDefault();
231                           event.stopPropagation();
232                           this.setState({ contextMenuInfo: { index, mouseX: event.clientX - 2, mouseY: event.clientY - 4 } });
233                         }
234                       }}
235                       role="checkbox"
236                       aria-checked={isSelected}
237                       aria-label="table-row"
238                       tabIndex={-1}
239                       key={entryId}
240                       selected={isSelected}
241                     >
242                       {this.props.enableSelection
243                         ? <TableCell padding="checkbox" style={{ width: "50px" }}>
244                           <Checkbox checked={isSelected} />
245                         </TableCell>
246                         : null
247                       }
248                       {
249                         this.props.columns.map(
250                           col => {
251                             const style = col.width ? { width: col.width } : {};
252                             return (
253                               <TableCell aria-label={col.title? col.title.toLowerCase().replace(/\s/g, "-") : col.property.toLowerCase().replace(/\s/g, "-")} key={col.property} align={col.type === ColumnType.numeric && !col.align ? "right" : col.align} style={style}>
254                                 {col.type === ColumnType.custom && col.customControl
255                                   ? <col.customControl className={col.className} style={col.style} rowData={entry} />
256                                   : col.type === ColumnType.boolean
257                                     ? <span className={col.className} style={col.style}>{col.labels ? col.labels[entry[col.property] ? "true" : "false"] : String(entry[col.property])}</span>
258                                     : <span className={col.className} style={col.style}>{String(entry[col.property])}</span>
259                                 }
260                               </TableCell>
261                             );
262                           }
263                         )
264                       }
265                       {<Menu open={!!contextMenu} onClose={() => this.setState({ contextMenuInfo: { index: -1 } })} anchorReference="anchorPosition" keepMounted
266                         anchorPosition={this.state.contextMenuInfo.mouseY != null && this.state.contextMenuInfo.mouseX != null ? { top: this.state.contextMenuInfo.mouseY, left: this.state.contextMenuInfo.mouseX } : undefined}>
267                         {contextMenu}
268                       </Menu> || null}
269                     </TableRow>
270                   );
271                 })}
272               {emptyRows > 0 && (
273                 <TableRow style={{ height: 49 * emptyRows }}>
274                   <TableCell colSpan={this.props.columns.length} />
275                 </TableRow>
276               )}
277             </TableBody>
278           </Table>
279         </TableContainer>
280         <TablePagination className={classes.pagination}
281           rowsPerPageOptions={[5, 10, 20, 50]}
282           component="div"
283           count={rowCount}
284           rowsPerPage={rowsPerPage}
285           page={page}
286           aria-label="table-pagination-footer"
287           backIconButtonProps={{
288             'aria-label': 'previous-page',
289           }}
290           nextIconButtonProps={{
291             'aria-label': 'next-page',
292           }}
293           onChangePage={this.onHandleChangePage}
294           onChangeRowsPerPage={this.onHandleChangeRowsPerPage}
295         />
296       </Paper>
297     );
298   }
299
300   static getDerivedStateFromProps(props: MaterialTableComponentProps, state: MaterialTableComponentState & { _rawRows: {}[] }): MaterialTableComponentState & { _rawRows: {}[] } {
301     if (isMaterialTableComponentPropsWithRowsAndRequestData(props)) {
302       return {
303         ...state,
304         rows: props.rows,
305         total: props.total,
306         orderBy: props.orderBy,
307         order: props.order,
308         filter: props.filter,
309         loading: props.loading,
310         showFilter: props.showFilter,
311         page: props.page,
312         rowsPerPage: props.rowsPerPage
313       }
314     } else if (isMaterialTableComponentPropsWithRows(props) && props.asynchronus && state._rawRows !== props.rows) {
315       const newState = MaterialTableComponent.updateRows(props, state);
316       return {
317         ...state,
318         ...newState,
319         _rawRows: props.rows || []
320       };
321     }
322     return state;
323   }
324
325   private static updateRows(props: MaterialTableComponentPropsWithRows, state: MaterialTableComponentState): { rows: {}[], total: number, page: number } {
326
327     const { page, rowsPerPage, order, orderBy, filter } = state;
328
329     try {
330       let data: dataType[] = props.rows || [];
331       let filtered = false;
332       if (state.showFilter) {
333         Object.keys(filter).forEach(prop => {
334           const exp = filter[prop];
335           filtered = filtered || exp !== undefined;
336           data = exp !== undefined ? data.filter((val) => {
337             const value = val[prop];
338
339             if (value) {
340
341               if (typeof exp === 'boolean') {
342                 return value == exp;
343
344               } else if (typeof exp === 'string') {
345
346                 const valueAsString = value.toString();
347                 if (exp.length === 0) return value;
348
349                 const regex = new RegExp("\\*", "g");
350                 const regex2 = new RegExp("\\?", "g");
351
352                 const countStar = (exp.match(regex) || []).length;
353                 const countQuestionmarks = (exp.match(regex2) || []).length;
354
355                 if (countStar > 0 || countQuestionmarks > 0) {
356                   let editableExpression = exp;
357
358                   if (!exp.startsWith('*')) {
359                     editableExpression = '^' + exp;
360                   }
361
362                   if (!exp.endsWith('*')) {
363                     editableExpression = editableExpression + '$';
364                   }
365
366                   const expressionAsRegex = editableExpression.replace(/\*/g, ".*").replace(/\?/g, ".");
367
368                   return valueAsString.match(new RegExp(expressionAsRegex, "g"));
369                 }
370                 else if (exp.includes('>=')) {
371                   return Number(valueAsString) >= Number(exp.replace('>=', ''));
372                 } else if (exp.includes('<=')) {
373                   return Number(valueAsString) <= Number(exp.replace('<=', ''));
374                 } else
375                   if (exp.includes('>')) {
376                     return Number(valueAsString) > Number(exp.replace('>', ''));
377                   } else if (exp.includes('<')) {
378                     return Number(valueAsString) < Number(exp.replace('<', ''));
379                   }
380               }
381             }
382
383             return (value == exp)
384           }) : data;
385         });
386       }
387
388       const rowCount = data.length;
389
390       if (page > 0 && rowsPerPage * page > rowCount) { //if result is smaller than the currently shown page, new search and repaginate
391         let newPage = Math.floor(rowCount / rowsPerPage);
392         return {
393           rows: data,
394           total: rowCount,
395           page: newPage
396         };
397       } else {
398         data = (orderBy && order
399           ? stableSort(data, getSorting(order, orderBy))
400           : data).slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
401
402         return {
403           rows: data,
404           total: rowCount,
405           page: page
406         };
407       }
408
409
410     } catch (e) {
411       console.error(e);
412       return {
413         rows: [],
414         total: 0,
415         page: page
416       }
417     }
418   }
419
420   private async update() {
421     if (isMaterialTableComponentPropsWithRequestData(this.props)) {
422       const response = await Promise.resolve(
423         this.props.onRequestData(
424           this.state.page, this.state.rowsPerPage, this.state.orderBy, this.state.order, this.state.showFilter && this.state.filter || {})
425       );
426       this.setState(response);
427     } else {
428       let updateResult = MaterialTableComponent.updateRows(this.props, this.state);
429       this.setState(updateResult);
430     }
431   }
432
433   private onFilterChanged = (property: string, filterTerm: string) => {
434     if (isMaterialTableComponentPropsWithRowsAndRequestData(this.props)) {
435       this.props.onFilterChanged(property, filterTerm);
436       return;
437     }
438     if (this.props.disableFilter) return;
439     const colDefinition = this.props.columns && this.props.columns.find(col => col.property === property);
440     if (colDefinition && colDefinition.disableFilter) return;
441
442     const filter = { ...this.state.filter, [property]: filterTerm };
443     this.setState({
444       filter
445     }, this.update);
446   };
447
448   private onHandleRequestSort = (event: React.SyntheticEvent, property: string) => {
449     if (isMaterialTableComponentPropsWithRowsAndRequestData(this.props)) {
450       this.props.onHandleRequestSort(property);
451       return;
452     }
453     if (this.props.disableSorting) return;
454     const colDefinition = this.props.columns && this.props.columns.find(col => col.property === property);
455     if (colDefinition && colDefinition.disableSorting) return;
456
457     const orderBy = this.state.orderBy === property && this.state.order === 'desc' ? null : property;
458     const order = this.state.orderBy === property && this.state.order === 'asc' ? 'desc' : 'asc';
459     this.setState({
460       order,
461       orderBy
462     }, this.update);
463   };
464
465   handleSelectAllClick: () => {};
466
467   private onHandleChangePage = (event: any | null, page: number) => {
468     if (isMaterialTableComponentPropsWithRowsAndRequestData(this.props)) {
469       this.props.onHandleChangePage(page);
470       return;
471     }
472     this.setState({
473       page
474     }, this.update);
475   };
476
477   private onHandleChangeRowsPerPage = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
478     if (isMaterialTableComponentPropsWithRowsAndRequestData(this.props)) {
479       this.props.onHandleChangeRowsPerPage(+(event && event.target.value));
480       return;
481     }
482     const rowsPerPage = +(event && event.target.value);
483     if (rowsPerPage && rowsPerPage > 0) {
484       this.setState({
485         rowsPerPage
486       }, this.update);
487     }
488   };
489
490   private isSelected(id: string | number): boolean {
491     let selected = this.state.selected || [];
492     const selectedIndex = selected.indexOf(id);
493     return (selectedIndex > -1);
494   }
495
496   private handleClick(event: any, rowData: TData, id: string | number): void {
497     if (this.props.onHandleClick instanceof Function) {
498       this.props.onHandleClick(event, rowData);
499       return;
500     }
501     if (!this.props.enableSelection) {
502       return;
503     }
504     let selected = this.state.selected || [];
505     const selectedIndex = selected.indexOf(id);
506     if (selectedIndex > -1) {
507       selected = [
508         ...selected.slice(0, selectedIndex),
509         ...selected.slice(selectedIndex + 1)
510       ];
511     } else {
512       selected = [
513         ...selected,
514         id
515       ];
516     }
517     this.setState({
518       selected
519     });
520   }
521
522
523   private exportToCsv = async () => {
524     let file;
525     let data: dataType[] | null = null;
526     let csv: string[] = [];
527
528     if (isMaterialTableComponentPropsWithRequestData(this.props)) {
529       // table with extra request handler
530       this.setState({ loading: true });
531       const result = await Promise.resolve(
532         this.props.onRequestData(0, 1000, this.state.orderBy, this.state.order, this.state.showFilter && this.state.filter || {})
533       );
534       data = result.rows;
535       this.setState({ loading: true });
536     } else if (isMaterialTableComponentPropsWithRowsAndRequestData(this.props)) {
537       // table with generated handlers note: exports data shown on current page
538       data = this.props.rows;
539     }
540     else {
541       // table with local data
542       data = MaterialTableComponent.updateRows(this.props, this.state).rows;
543     }
544
545     if (data && data.length > 0) {
546       csv.push(this.props.columns.map(col => col.title || col.property).join(',') + "\r\n");
547       this.state.rows && this.state.rows.forEach((row: any) => {
548         csv.push(this.props.columns.map(col => row[col.property]).join(',') + "\r\n");
549       });
550       const properties = { type: "text/csv;charset=utf-8" }; // Specify the file's mime-type.
551       try {
552         // Specify the filename using the File constructor, but ...
553         file = new File(csv, "export.csv", properties);
554       } catch (e) {
555         // ... fall back to the Blob constructor if that isn't supported.
556         file = new Blob(csv, properties);
557       }
558     }
559     if (!file) return;
560     var reader = new FileReader();
561     reader.onload = function (e) {
562       const dataUri = reader.result as any;
563       const link = document.createElement("a");
564       if (typeof link.download === 'string') {
565         link.href = dataUri;
566         link.download = "export.csv";
567
568         //Firefox requires the link to be in the body
569         document.body.appendChild(link);
570
571         //simulate click
572         link.click();
573
574         //remove the link when done
575         document.body.removeChild(link);
576       } else {
577         window.open(dataUri);
578       }
579     }
580     reader.readAsDataURL(file);
581
582     // const url = URL.createObjectURL(file);
583     // window.location.replace(url);
584   }
585 }
586
587 export type MaterialTableCtorType<TData extends {} = {}> = new () => React.Component<Omit<MaterialTableComponentProps<TData>, 'classes'>>;
588
589 export const MaterialTable = withStyles(styles)(MaterialTableComponent);
590 export default MaterialTable;