Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reusable table component and example #149

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/core/table/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Table } from 'core/table/table';

export const Table = { Table };
268 changes: 268 additions & 0 deletions src/core/table/table.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import React, { useState, useMemo } from 'react';
import PropTypes from 'prop-types'
import _ from 'lodash';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If possible we could import functions individually, so that it can be tree-shaked

Suggested change
import _ from 'lodash';
import { noop } from 'lodash';


import Box from '@mui/material/Box';
import {
Table as MuiTable,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TableSortLabel,
Paper,
Checkbox,
Radio,
CircularProgress
} from '@mui/material';
import { visuallyHidden } from '@mui/utils';

/*
@columns: Array of objects
@column: {
id: Unique identifier
accessor: Property to be accessed
label: Header label
template: custom template
}
*/

export const Table = ({
data,
columns = [],
loading = false,
onRowClick = _.noop,
onRowSelect = _.noop,
defaultOrder = 'asc',
defaultOrderBy = '',
multiSelect = false
}) => {
const [order, setOrder] = useState(defaultOrder);
const [orderBy, setOrderBy] = useState(defaultOrderBy);
const [selected, setSelected] = React.useState([]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useState is already imported

Suggested change
const [selected, setSelected] = React.useState([]);
const [selected, setSelected] = useState([]);

const [page, setPage] = React.useState(0);
const [rowsPerPage, setRowsPerPage] = React.useState(5);

const processedData = useMemo(() => {
return data.map(entry => {
return {
original: entry,
_id: _.uniqueId('table__entry__')
};
});
}, [data]);

const processedColumns = useMemo(() => columns.filter(({ show = true }) => show));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hook will always run because it has no dependencies, making memoizing not useful


const handleRequestSort = (event, property) => {
const isAsc = orderBy === property && order === 'asc';
setOrder(isAsc ? 'desc' : 'asc');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could there be an "enum" for asc/desc to be used across this component? Thinking of benefit of auto complete and prevent typos

const orderType = {
  asc: 'asc',
  desc: 'desc'
};

setOrderBy(property);
};

const handleClick = (event, rawInfo) => {
const selectedIndex = selected.findIndex(entry => entry._id === rawInfo._id);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use the isSelected function

let newSelected = [];

if (selectedIndex === -1) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this logic be moved to a different function

newSelected = newSelected.concat(selected, rawInfo);
} else if (selectedIndex === 0) {
newSelected = newSelected.concat(selected.slice(1));
} else if (selectedIndex === selected.length - 1) {
newSelected = newSelected.concat(selected.slice(0, -1));
} else if (selectedIndex > 0) {
newSelected = newSelected.concat(
selected.slice(0, selectedIndex),
selected.slice(selectedIndex + 1)
);
}

if (!multiSelect) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check should be run before the rest, as from the reader standpoint doesnt make sense to go through 4 checks so that in the end is reasigned

newSelected = [rawInfo];
}

setSelected(newSelected);
onRowSelect(newSelected);
onRowClick(rawInfo);
};

const createSortHandler = (property) => (event) => {
handleRequestSort(event, property);
};

const isSelected = (id) => selected.findIndex((entry) => entry._id === id) !== -1;

const emptyRows = page > 0 ? Math.max(0, (1 + page) * rowsPerPage - processedData.length) : 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Math.max is already going establish a lower limit to 0, no?

Suggested change
const emptyRows = page > 0 ? Math.max(0, (1 + page) * rowsPerPage - processedData.length) : 0;
const emptyRows = Math.max(0, (1 + page) * rowsPerPage - processedData.length);


const renderTableHead = () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many of these functions could be placed in a separate file, as it clogs this whole file

return (
<TableHead>
<TableRow>
<TableCell></TableCell>
{processedColumns.map(({ id, accessor = '', label, style }) => {
return (
<TableCell
sx={{ ...style }}
key={id}
sortDirection={orderBy === accessor ? order : false}>
<TableSortLabel
active={orderBy === accessor}
direction={orderBy === accessor ? order : 'asc'}
onClick={createSortHandler(accessor)}
>
{label}
{orderBy === accessor ? (
<Box component="span" sx={visuallyHidden}>
{order === 'desc' ? 'sorted descending' : 'sorted ascending'}
</Box>
) : null}
</TableSortLabel>
</TableCell>
)
})}
</TableRow>
</TableHead>
)
}

const handleChangePage = (event, newPage) => {
setPage(newPage);
};

const handleChangeRowsPerPage = (event) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};

function descendingComparator(a, b, orderBy) {
if (b[orderBy] < a[orderBy]) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic could be simplified to

Suggested change
if (b[orderBy] < a[orderBy]) {
return b[orderBy] - a[orderBy];

But further, the below function could be simplified to

const scalar = order === "desc" ? 1 : -1;
return scalar * (b.original[orderBy] - a.original[orderBy]);

And this logic as they are pure functions, they could be entirely moved to a helper file with some test suite

return -1;
}
if (b[orderBy] > a[orderBy]) {
return 1;
}
return 0;
}

function getComparator(order, orderBy) {
return order === "desc"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see there is a code repetition, it could be shortened to:

Suggested change
return order === "desc"
const scalar = order === "desc" ? 1 : -1;
return scalar * descendingComparator(a.original, b.original, orderBy);

? (a, b) => descendingComparator(a.original, b.original, orderBy)
: (a, b) => -descendingComparator(a.original, b.original, orderBy);
}

function stableSort(array, comparator) {
const stabilizedThis = array.map((el, index) => [el, index]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is possible to chain these methods, such as (also added an alternative to the double return)

array
  .map((el, index) => [el, index])
  .sort((a, b) => {
    const elementOrder = comparator(a[0], b[0]);
    const decisiveOrder = elementOrder === 0 ? a[1] - b[1] : elementOrder;

    return decisiveOrder;
  })
  .map((el) => el[0]);

stabilizedThis.sort((a, b) => {
const order = comparator(a[0], b[0]);
if (order !== 0) {
return order;
}
return a[1] - b[1];
});
return stabilizedThis.map((el) => el[0]);
}

const renderSelectCell = (checked, labelId) => {
const multiSelectCell = (
<TableCell padding="checkbox">
<Checkbox
color="primary"
checked={checked}
inputProps={{
'aria-labelledby': labelId,
}}
/>
</TableCell>
);

const singleSelectCell = (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if by assigning this component straight to the const React will execute (not necessary render) code that is inside of it, it is worth to check.

Also the code <TableCell padding="checkbox"> is repeated, it could be returned regardless and then composed with Checkbox / Radio

<TableCell padding="checkbox">
<Radio
checked={checked}
name="radio-button"
inputProps={{ 'aria-labelledby': labelId }}
/>
</TableCell>
);
return multiSelect ? multiSelectCell : singleSelectCell;
}

const renderTableBody = () => {
return (
<TableBody>
{stableSort(processedData, getComparator(order, orderBy))
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((row, index) => {
const isItemSelected = isSelected(row._id);
const labelId = `enhanced-table-checkbox-${index}`;

return (
<TableRow
hover
onClick={(event) => handleClick(event, row)}
role="checkbox"
aria-checked={isItemSelected}
tabIndex={-1}
key={row.name}
selected={isItemSelected}
sx={{ height: '100px' }}
>
{onRowSelect !== _.noop && renderSelectCell(isItemSelected, labelId)}
{processedColumns.map(column => {
const Template = column.template;


return Template ? (
<TableCell>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TableCell could wrap around the actual alternatives

Suggested change
<TableCell>
<TableCell>
{Template ? <Template row={row} /> : row.original[column.accessor]}
</TableCell>

<Template row={row} />
</TableCell>
) : (
<TableCell>
{row.original[column.accessor]}
</TableCell>
)
})}
</TableRow>
);
})}
{emptyRows > 0 && (
<TableRow>
<TableCell colSpan={6} />
</TableRow>
)}
</TableBody>
)
}

const tableContainer = (
<>
<TableContainer>
<MuiTable sx={{ minWidth: 750, height: 500 }}>
{renderTableHead()}
{renderTableBody()}
</MuiTable>
</TableContainer>
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
component="div"
count={processedData.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</>
)

return (
<Box sx={{ width: '100%' }}>
<Paper sx={{ width: '100%', mb: 2 }}>
{loading ? <div style={{height: 500, display: 'flex', justifyContent: 'center', alignItems: 'center'}}><CircularProgress /></div> : tableContainer}
</Paper>
</Box>
)
};

Table.propTypes = { columns: PropTypes.array, data: PropTypes.array }
93 changes: 93 additions & 0 deletions src/core/table/tableExample.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React from 'react';
import { Link } from '@mui/material';
import { Table } from './table';

export const TableExample = () => {
const genericAction = (event, action, row) => {
console.log(`${action} `, row)
event.stopPropagation();
}

const columns = [
{
id: 'lessonName',
accessor: 'lessonName',
label: 'DENUMIRE LECTIE'
},
{
id: 'category',
accessor: 'category',
label: 'CATEGORIE',
},
{
id: 'status',
accessor: 'status',
label: 'STATUS',
},
{
id: 'actions',
label: 'ACTIUNI',
template: ({row}) => {
return (
<div style={{
display: 'flex',
justifyContent: 'space-between',
}}>
<Link underline="none" onClick={(e) => genericAction(e, 'EDIT: ', row)}>Editeaza</Link>
<Link underline="none" onClick={(e) => genericAction(e, 'DUPLICATE: ', row)}>Duplica lectie</Link>
<Link underline="none" onClick={(e) => genericAction(e, 'DELETE: ', row)}>Sterge</Link>
</div>
)
},
style: {
minWidth: '180px',
}
}
];

const data = [
{
lessonName: 'Asertivitate si managementul conflictelor1',
category: 'One',
status: 'Publicata',
},
{
lessonName: 'Asertivitate si managementul conflictelor2',
category: 'One',
status: 'Publicata',
},
{
lessonName: 'Asertivitate si managementul conflictelor3',
category: 'One',
status: 'Publicata',
},
{
lessonName: 'Asertivitate si managementul conflictelor4',
category: 'One',
status: 'Publicata',
},
{
lessonName: 'Asertivitate si managementul conflictelor5',
category: 'One',
status: 'Publicata',
},
{
lessonName: 'Asertivitate si managementul conflictelor6',
category: 'One',
status: 'Publicata',
}
]

return (
<Table
columns={columns}
data={data}
onRowSelect={rows => console.log(rows)}
onRowClick={row => console.log(row)}
loading={false}
defaultOrder={'asc'}
defaultOrderBy={'lessonName'}
multiSelect={false}
/>
)
};