quite-simple-reactdatatable
Version:
A simple component to display data on a table, with pagination, search, column specific search, and column asc/desc sorting.
403 lines (378 loc) • 20.7 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('react'), require('prop-types')) :
typeof define === 'function' && define.amd ? define(['exports', 'react', 'prop-types'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global["quite-simple-reactdatatable"] = {}, global.React, global.PropTypes));
})(this, (function (exports, React, PropTypes) { 'use strict';
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var PropTypes__namespace = /*#__PURE__*/_interopNamespaceDefault(PropTypes);
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
const TableRow = ({
item,
columns,
onRowClick,
visibleColumns
}) => {
return /*#__PURE__*/React.createElement("tr", {
onClick: () => onRowClick(item)
}, columns.filter(col => visibleColumns.has(col.key)).map(column => /*#__PURE__*/React.createElement("td", {
key: column.key
}, item[column.key])));
};
function styleInject(css, ref) {
if (ref === void 0) ref = {};
var insertAt = ref.insertAt;
if (!css || typeof document === 'undefined') {
return;
}
var head = document.head || document.getElementsByTagName('head')[0];
var style = document.createElement('style');
style.type = 'text/css';
if (insertAt === 'top') {
if (head.firstChild) {
head.insertBefore(style, head.firstChild);
} else {
head.appendChild(style);
}
} else {
head.appendChild(style);
}
if (style.styleSheet) {
style.styleSheet.cssText = css;
} else {
style.appendChild(document.createTextNode(css));
}
}
var css_248z = ".employee-filter{\r\n display:flex;\r\n flex-direction: row;\r\n margin-bottom: 20px;\r\n}\r\n\r\ntable {\r\n width: 100%;\r\n border-collapse: collapse;\r\n overflow-y: scroll;\r\n}\r\n\r\ntable, th, td {\r\n border: none;\r\n}\r\nth{\r\n border:none;\r\n}\r\ntable td {\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n overflow: hidden;\r\n}\r\n.tablecontent{\r\n width: 100%;\r\n border-collapse: collapse;\r\n}\r\nth, td {\r\n padding: 8px 12px;\r\n text-align: left;\r\n}\r\nthead{\r\n position: sticky;\r\n top: 0;\r\n z-index: 10;\r\n}\r\nth {\r\n position: relative;\r\n background-color: #333; \r\n color: white;\r\n height: 40px;\r\n padding-right:20px;\r\n}\r\n\r\nth:hover {\r\n cursor: pointer;\r\n}\r\n\r\n\r\ntd {\r\n min-width: 50px;\r\n max-width: 50px;\r\n}\r\n\r\ntbody{\r\n margin-top: 40px;\r\n}\r\ntbody tr:hover {\r\n background-color: #f9f9f9; \r\n cursor: pointer;\r\n}\r\n\r\ninput[type=\"text\"] {\r\n padding: 5px;\r\n box-sizing: border-box;\r\n border: 1px solid #ccc;\r\n border-radius: 4px;\r\n}\r\n\r\nselect {\r\n padding: 7.9px;\r\n box-sizing: border-box;\r\n border: 1px solid #ccc;\r\n border-radius: 4px;\r\n margin-left: 10px;\r\n}\r\n\r\nselect:hover {\r\n cursor: pointer;\r\n}\r\n\r\n.search{\r\n margin-left: 10px;\r\n}\r\n\r\n#startDate, #dateOfBirth:hover {\r\n cursor: pointer;\r\n}\r\n\r\n.pagination{\r\n margin-bottom: 40px;\r\n margin-top: 20px;\r\n}\r\n\r\n\r\n.employee-list, .employee-details, .employee-info {\r\n padding: 10px;\r\n box-sizing: border-box;\r\n}\r\n\r\n.datatable-container{\r\n height: 100%;\r\n}\r\n.datatable-table-overflow-container {\r\n overflow-y: scroll;\r\n border: 1px solid #ccc;\r\n margin-bottom: 2rem;\r\n}\r\n\r\n.datatable-header{\r\n display: flex;\r\n flex-direction: row;\r\n align-items: center;\r\n white-space: nowrap;\r\n}\r\n\r\n.datatable-header > label{\r\n margin-right: 10px;\r\n margin-bottom: 20px;\r\n}\r\n.dropdown > label {\r\n margin-right: 10px;\r\n \r\n}\r\n\r\n.pagination-text{\r\n margin-left: 10px;\r\n margin-right: 10px;\r\n}\r\n.sort-indicator {\r\n position: absolute;\r\n right: 5px;\r\n top: 50%;\r\n transform: translateY(-50%);\r\n}\r\n\r\n.sort-indicator.asc::before {\r\n content: \"▲\";\r\n}\r\n\r\n.sort-indicator.desc::before {\r\n content: \"▼\";\r\n}\r\n.sort-indicator {\r\n visibility: hidden; \r\n}\r\n\r\n.sort-indicator.active {\r\n visibility: visible;\r\n}\r\n\r\n\r\n.dropdown {\r\n position: relative;\r\n margin-right: 10px;\r\n margin-bottom: 15px; \r\n \r\n}\r\n\r\n.dropdown-content {\r\n display: none;\r\n position: absolute;\r\n background-color: #f9f9f9;\r\n min-width: 160px;\r\n box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);\r\n z-index: 20;\r\n}\r\n\r\n.dropdown-content div {\r\n color: black;\r\n padding: 8px 12px;\r\n text-decoration: none;\r\n display: block;\r\n}\r\n\r\n.dropdown-content div:hover {\r\n background-color: #f1f1f1;\r\n}\r\n\r\n.dropdown:hover .dropdown-content {\r\n display: block;\r\n}\r\n\r\n.dropdown > label {\r\n cursor: pointer;\r\n transition: background-color 0.3s;\r\n background-color: #eee;\r\n padding: 6.41px;\r\n border-radius: 4px;\r\n}\r\n\r\n.dropdown > label:hover {\r\n background-color: #ddd;\r\n}\r\n\r\n.dropdown > label::after {\r\n content: \"⌵\";\r\n margin-left: 5px;\r\n}\r\n\r\n.dropdown-content input[type=\"checkbox\"] {\r\n margin-right: 10px;\r\n transform: scale(1.2); \r\n cursor: pointer;\r\n\r\n}\r\n\r\n.dropdown-content div {\r\n margin: 8px 0;\r\n padding: 4px 8px;\r\n cursor: pointer;\r\n}\r\n\r\n.search-input-container {\r\n position: relative;\r\n display: flex;\r\n align-items: center;\r\n}\r\n\r\nlabel .search-input {\r\n padding: 7.9px 7.9px 7.9px 24px;\r\n box-sizing: border-box;\r\n border: 1px solid #ccc;\r\n border-radius: 4px;\r\n width: 100%;\r\n}\r\n\r\n.search-icon {\r\n position: absolute;\r\n left: 5px; \r\n\r\n width: 10px;\r\n height: 10px;\r\n border: solid 2px #635757;\r\n border-radius: 50%;\r\n}\r\n\r\n.search-icon:after {\r\n content: '';\r\n position: absolute;\r\n width: 2px;\r\n height: 5.5px;\r\n border-radius: 2px 2px 0px 0px;\r\n background-color: #635757;\r\n \r\n bottom: -4px;\r\n right: -2.5px;\r\n transform: rotate(136deg);\r\n}\r\n\r\n.search-label{\r\n display: flex;\r\n justify-content: center;\r\n align-items: center;\r\n}\r\n\r\nbutton{\r\n background-color: #333;\r\n color: #ffffff;\r\n border: transparent solid 1px;\r\n padding: 10px 20px;\r\n text-align: center;\r\n text-decoration: none;\r\n display: inline-block;\r\n font-size: 16px;\r\n margin: 4px 2px;\r\n cursor: pointer;\r\n border-radius: 4px;\r\n transition: background-color 0.3s;\r\n}\r\n\r\nbutton:hover {\r\n background-color: #ffffff;\r\n color: #333;\r\n border: #333 solid 1px;\r\n}\r\n";
styleInject(css_248z);
//Memoized version of TableRow component to prevent unnecessary re-renders as memo does a shallow comparison of props
const MemoizedTableRow = /*#__PURE__*/React.memo(TableRow);
//Main component ReactDataTable
//Uses useReducer hook to manage state
function ReactDataTable({
data = [],
columns = [],
onRowClick = () => {},
defaultEntriesPerPage = 10,
sortColumnParam = 'name',
headerHeight = 'auto',
tableBodyHeight = 'auto',
paginationHeight = 'auto',
headerFontSize = '1rem',
tableBodyFontSize = '1rem',
paginationFontSize = '1rem',
fontFamily = 'Arial',
containerWidth = '100%'
}) {
//Dispatch an action to set the filtered data when the data prop changes
React.useEffect(() => {
dispatch({
type: 'SET_FILTERED_DATA',
payload: data
});
}, [data]);
//Object with every property needed for the table
//Is used in the useReducer hook as the initial state
//and will be updated by the reducer function depending on the action dispatched
const initialState = {
searchTerm: '',
filteredData: data,
searchColumn: 'all',
currentPage: 1,
entriesPerPage: defaultEntriesPerPage,
sortColumn: {
key: sortColumnParam,
direction: 'neutral'
},
visibleColumns: new Set(columns.map(col => col.key))
};
//Reducer, will return the corresponding new state when an action is dispatched
//action type (ex: 'SET_SEARCH_TERM') determines which property of the state will be updated
//action payload (ex: 'John') is the new value of the property
function reducer(state, action) {
switch (action.type) {
case 'SET_SEARCH_TERM':
//Search input
return {
...state,
searchTerm: action.payload
};
case 'SET_FILTERED_DATA':
//Search results (or received data when the search input is empty)
return {
...state,
filteredData: action.payload
};
case 'SET_SEARCH_COLUMN':
//Column selected to search in
return {
...state,
searchColumn: action.payload
};
case 'SET_CURRENT_PAGE':
//Page viewed in the table
return {
...state,
currentPage: action.payload
};
case 'SET_ENTRIES_PER_PAGE':
//Page size
return {
...state,
entriesPerPage: action.payload
};
case 'SET_SORT_COLUMN':
//Sort selected column in ascending, descending or neutral order
return {
...state,
sortColumn: action.payload
};
default:
return state;
}
}
//useReducer hook which will update the state via the reducer function
//function reducer is the first parameter, initialState (ex:
//It updates the state with the results of the reducer function
const [state, dispatch] = React.useReducer(reducer, initialState);
//Function to search for a value in the data
//It is used with a debounce in the handleSearch function
const search = React.useCallback((name, items) => {
if (!Array.isArray(items)) {
console.error('Expected an array for items');
return [];
}
const results = new Set(); //A set is used to avoid duplicates
const searchTerms = name.toLowerCase().split(/\s+/); //We split the search value to search for each word
items.forEach(item => {
let concatenatedData = '';
columns.forEach(column => {
if (state.searchColumn === 'all' || state.searchColumn === column.key) {
concatenatedData += ' ' + String(item[column.key]).toLowerCase();
}
});
const dataSegments = concatenatedData.split(/\s+/);
let termFound = false;
for (let term of searchTerms) {
if (dataSegments.some(segment => segment.includes(term))) {
termFound = true;
} else {
termFound = false;
break;
}
}
if (termFound) {
results.add(item);
}
});
return Array.from(results);
}, [state.searchColumn, columns]);
//Debounce function to call search function with a delay
const debouncedSearch = React.useCallback(debounce(searchValue => {
let results;
if (searchValue === '') {
results = data;
} else {
results = search(searchValue, data);
}
dispatch({
type: 'SET_FILTERED_DATA',
payload: results
});
}, 150), [data, dispatch, search]);
//onRowClick is the function passed as a prop
//only re-created when the onRowClick prop changes
const memoizedOnRowClick = React.useCallback(item => onRowClick(item), [onRowClick]);
//Used to send the search value to the search function when the search input changes
const handleSearch = e => {
dispatch({
type: 'SET_SEARCH_TERM',
payload: e.target.value
});
debouncedSearch(e.target.value);
};
//Props Styles
const containerStyle = {
width: containerWidth
};
const headerStyle = {
height: headerHeight,
fontSize: headerFontSize,
fontFamily
};
const tableBodyStyle = {
height: tableBodyHeight,
fontSize: tableBodyFontSize,
fontFamily
};
const paginationStyle = {
height: paginationHeight,
fontSize: paginationFontSize,
fontFamily
};
//Memoized version of the sorted data, will be used by totalPages and currentEntries
//to calculate the number of pages and the entries to display in the current page
//Sorts the data when: any data change or search input change
const sortedData = React.useMemo(() => {
let array = [...state.filteredData];
if (state.sortColumn.direction === 'neutral') {
return array;
}
return array.sort((a, b) => {
if (state.sortColumn.direction === 'asc') {
return String(a[state.sortColumn.key]).localeCompare(String(b[state.sortColumn.key]));
} else {
return String(b[state.sortColumn.key]).localeCompare(String(a[state.sortColumn.key]));
}
});
}, [state.filteredData, state.sortColumn]); //Only re-sort when the filtered data or sort column changes
//Function to handle the click on a column header
//It will dispatch an action to update the sortColumn
const handleHeaderClick = columnKey => {
let newDirection = 'asc'; //Default to ascending on the first click
//If the current sort column is the same as the clicked column, toggle the direction
if (state.sortColumn.key === columnKey) {
if (state.sortColumn.direction === 'asc') {
newDirection = 'desc';
} else if (state.sortColumn.direction === 'desc') {
newDirection = 'neutral'; //Reset to neutral/unsorted state
}
}
dispatch({
type: 'SET_SORT_COLUMN',
payload: {
key: columnKey,
direction: newDirection
}
});
};
//Calculate the total number of pages based on the number of entries per page
const totalPages = Math.ceil(sortedData.length / state.entriesPerPage);
//Get the entries to display based on the current page and entries per page
const currentEntries = sortedData.slice((state.currentPage - 1) * state.entriesPerPage, state.currentPage * state.entriesPerPage);
return /*#__PURE__*/React.createElement("div", {
className: "datatable-container",
style: containerStyle
}, /*#__PURE__*/React.createElement("div", {
className: "datatable-header",
style: headerStyle
}, /*#__PURE__*/React.createElement("label", null, "Show entries:", /*#__PURE__*/React.createElement("select", {
value: state.entriesPerPage,
onChange: e => dispatch({
type: 'SET_ENTRIES_PER_PAGE',
payload: Number(e.target.value)
})
}, /*#__PURE__*/React.createElement("option", {
value: 10
}, "10"), /*#__PURE__*/React.createElement("option", {
value: 25
}, "25"), /*#__PURE__*/React.createElement("option", {
value: 50
}, "50"), /*#__PURE__*/React.createElement("option", {
value: 100
}, "100"))), /*#__PURE__*/React.createElement("label", null, "Search Column:", /*#__PURE__*/React.createElement("select", {
value: state.searchColumn,
onChange: e => dispatch({
type: 'SET_SEARCH_COLUMN',
payload: e.target.value
})
}, /*#__PURE__*/React.createElement("option", {
value: "all"
}, "All"), columns.map(column => /*#__PURE__*/React.createElement("option", {
key: column.key,
value: column.key
}, column.title)))), /*#__PURE__*/React.createElement("label", {
className: "search-label search-input-container"
}, /*#__PURE__*/React.createElement("input", {
type: "text",
value: state.searchTerm,
onChange: handleSearch,
className: "search-input"
}), /*#__PURE__*/React.createElement("div", {
className: "search-icon"
}))), /*#__PURE__*/React.createElement("div", {
className: "datatable-table-overflow-container",
style: tableBodyStyle
}, /*#__PURE__*/React.createElement("table", null, /*#__PURE__*/React.createElement("thead", null, /*#__PURE__*/React.createElement("tr", null, columns.filter(col => state.visibleColumns.has(col.key)).map(column => /*#__PURE__*/React.createElement("th", {
key: column.key,
onClick: () => handleHeaderClick(column.key),
"data-testid": `column-${column.key}`
}, column.title, /*#__PURE__*/React.createElement("span", {
className: `sort-indicator ${state.sortColumn.key === column.key ? (state.sortColumn.direction !== 'neutral' ? 'active ' : '') + state.sortColumn.direction : ''}`
}))))), /*#__PURE__*/React.createElement("tbody", null, currentEntries.map(item => /*#__PURE__*/React.createElement(MemoizedTableRow, {
key: item.id,
item: item,
columns: columns,
onRowClick: memoizedOnRowClick,
visibleColumns: state.visibleColumns
}))))), /*#__PURE__*/React.createElement("div", {
className: "datatable-pagination",
style: paginationStyle
}, /*#__PURE__*/React.createElement("button", {
onClick: () => dispatch({
type: 'SET_CURRENT_PAGE',
payload: Math.max(state.currentPage - 1, 1)
})
}, "Previous"), /*#__PURE__*/React.createElement("span", {
className: "pagination-text"
}, `Showing ${Math.min((state.currentPage - 1) * state.entriesPerPage + 1, state.filteredData.length)} to ${Math.min(state.currentPage * state.entriesPerPage, state.filteredData.length)} of ${state.filteredData.length} entries`), /*#__PURE__*/React.createElement("button", {
onClick: () => dispatch({
type: 'SET_CURRENT_PAGE',
payload: Math.min(state.currentPage + 1, totalPages)
})
}, "Next")));
}
//Proptypes to validate props passed to ReactDataTable
ReactDataTable.propTypes = {
data: PropTypes__namespace.array.isRequired,
columns: PropTypes__namespace.arrayOf(PropTypes__namespace.shape({
key: PropTypes__namespace.string.isRequired,
title: PropTypes__namespace.string.isRequired
})).isRequired,
onRowClick: PropTypes__namespace.func,
defaultEntriesPerPage: PropTypes__namespace.number,
sortColumnParam: PropTypes__namespace.string,
headerHeight: PropTypes__namespace.string,
tableBodyHeight: PropTypes__namespace.string,
paginationHeight: PropTypes__namespace.string,
headerWidth: PropTypes__namespace.string,
tableWidth: PropTypes__namespace.string,
paginationWidth: PropTypes__namespace.string,
headerFontSize: PropTypes__namespace.string,
tableBodyFontSize: PropTypes__namespace.string,
paginationFontSize: PropTypes__namespace.string,
fontFamily: PropTypes__namespace.string
};
TableRow.propTypes = {
item: PropTypes__namespace.object.isRequired,
columns: PropTypes__namespace.array.isRequired,
onRowClick: PropTypes__namespace.func.isRequired,
visibleColumns: PropTypes__namespace.instanceOf(Set).isRequired
};
exports.ReactDataTable = ReactDataTable;
}));
//# sourceMappingURL=bundle.js.map