339 lines
15 KiB
JavaScript
339 lines
15 KiB
JavaScript
import React, {useCallback, useEffect, useState} from 'react';
|
|
import {useTheme} from "../../hooks/themeHook";
|
|
import {Card, Dropdown, InputGroup} from "react-bootstrap";
|
|
import Select from "../input/Select";
|
|
import SearchField from "../input/SearchField";
|
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
|
import {
|
|
faAngleLeft,
|
|
faAngleRight,
|
|
faAnglesLeft,
|
|
faAnglesRight,
|
|
faSort,
|
|
faSortAsc,
|
|
faSortDesc
|
|
} from "@fortawesome/free-solid-svg-icons";
|
|
import Toggle from "../input/Toggle";
|
|
import CollapseWithButton from "../layout/CollapseWithButton";
|
|
import {faMinusSquare, faPlusSquare} from "@fortawesome/free-regular-svg-icons";
|
|
import {Trans, useTranslation} from "react-i18next";
|
|
import {download, generateCsv, mkConfig} from "export-to-csv";
|
|
|
|
const PaginationOption = ({onClick, children, selected}) => (
|
|
<li>
|
|
<button className={"btn " + (selected ? "bg-theme" : '')} onClick={onClick}>{children}</button>
|
|
</li>
|
|
)
|
|
|
|
const Pagination = ({page, maxPage, setPage}) => {
|
|
const firstPage = () => setPage(0);
|
|
const previousPage = () => setPage(Math.max(0, page - 1));
|
|
const nextPage = () => setPage(Math.min(maxPage - 1, page + 1));
|
|
const lastPage = () => setPage(maxPage - 1);
|
|
|
|
const elements = [];
|
|
elements.push(<PaginationOption key={"<<"} onClick={firstPage}><FontAwesomeIcon
|
|
icon={faAnglesLeft}/></PaginationOption>)
|
|
elements.push(<PaginationOption key={"<"} onClick={previousPage}><FontAwesomeIcon
|
|
icon={faAngleLeft}/></PaginationOption>)
|
|
const pagesStart = Math.max(1, page - 2);
|
|
const pagesEnd = Math.min(maxPage, pagesStart + 7);
|
|
for (let i = pagesStart; i <= pagesEnd; i++) {
|
|
elements.push(<PaginationOption key={i} selected={page === i - 1}
|
|
onClick={() => setPage(i - 1)}>{i}</PaginationOption>)
|
|
}
|
|
elements.push(<PaginationOption key={">"} onClick={nextPage}><FontAwesomeIcon
|
|
icon={faAngleRight}/></PaginationOption>)
|
|
elements.push(<PaginationOption key={">>"} onClick={lastPage}><FontAwesomeIcon
|
|
icon={faAnglesRight}/></PaginationOption>)
|
|
|
|
return (
|
|
<ul className={"dataTables_paginate input-group pagination"}>
|
|
{elements}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
const SortIcon = ({selected, reversed}) => {
|
|
if (!selected) return <FontAwesomeIcon className={"opaque-text"} icon={faSort}/>;
|
|
if (reversed) return <FontAwesomeIcon icon={faSortAsc}/>;
|
|
return <FontAwesomeIcon icon={faSortDesc}/>;
|
|
}
|
|
|
|
const VisibleColumnsSelector = ({columns, visibleColumnIndexes, toggleColumn}) => {
|
|
const {t} = useTranslation();
|
|
return (
|
|
<CollapseWithButton title={t('html.label.table.visibleColumns')} coverToggle>
|
|
<Card style={{position: "absolute", zIndex: 3}}>
|
|
<Card.Body>
|
|
<ul style={{listStyle: "none", paddingLeft: "0.25rem", margin: 0}}>
|
|
{columns.map((column, i) => {
|
|
return <li key={JSON.stringify(column.data)}>
|
|
<Toggle value={visibleColumnIndexes.includes(i)}
|
|
onValueChange={() => toggleColumn(i)}>{column.title}</Toggle>
|
|
</li>
|
|
})}
|
|
</ul>
|
|
</Card.Body>
|
|
</Card>
|
|
</CollapseWithButton>
|
|
)
|
|
}
|
|
|
|
const ExportMenu = ({matchingData}) => {
|
|
const {t} = useTranslation();
|
|
const [generating, setGenerating] = useState(false);
|
|
|
|
const hasData = matchingData.length > 0;
|
|
|
|
const exportCSV = useCallback(async () => {
|
|
setGenerating(true);
|
|
|
|
const rows = matchingData.map(row => {
|
|
const mapped = {};
|
|
for (let entry of Object.entries(row)) {
|
|
if (entry[1] === undefined || entry[1]["$$typeof"] === undefined) {
|
|
mapped[entry[0]] = entry[1];
|
|
}
|
|
}
|
|
return mapped;
|
|
})
|
|
|
|
const csvConfig = mkConfig({
|
|
useKeysAsHeaders: true,
|
|
filename: "data-" + new Date().toISOString().replaceAll(":", '').substring(0, 17)
|
|
});
|
|
const csvOutput = generateCsv(csvConfig)(rows);
|
|
await download(csvConfig)(csvOutput);
|
|
setGenerating(false)
|
|
}, [matchingData, setGenerating])
|
|
|
|
return (
|
|
<>{hasData &&
|
|
<Dropdown>
|
|
<Dropdown.Toggle variant={""} id="dropdown-basic">
|
|
{generating &&
|
|
<FontAwesomeIcon icon={"gear"} className={"fa-spin"} title={t('html.label.export')}/>}
|
|
{!generating && <FontAwesomeIcon icon={"file-export"} title={t('html.label.export')}/>}
|
|
</Dropdown.Toggle>
|
|
<Dropdown.Menu>
|
|
<Dropdown.Item onClick={exportCSV}><FontAwesomeIcon
|
|
icon={"file-export"}/> {t('html.label.export')} CSV</Dropdown.Item>
|
|
</Dropdown.Menu>
|
|
</Dropdown>
|
|
}</>
|
|
)
|
|
}
|
|
|
|
const DataTablesTable = ({id, rowKeyFunction, options, colorClass}) => {
|
|
const {t} = useTranslation();
|
|
const {nightModeEnabled} = useTheme();
|
|
|
|
const columns = options.columns;
|
|
const [sortBy, setSortBy] = useState(options.order[0][0] || 0);
|
|
const [sortReversed, setSortReversed] = useState(options.order[0][1] === 'asc');
|
|
const [visibleColumnIndexes, setVisibleColumnIndexes] = useState(columns.map((_, i) => i));
|
|
const toggleColumn = useCallback(index => {
|
|
const currentSortIndex = visibleColumnIndexes[sortBy];
|
|
if (visibleColumnIndexes.includes(index)) {
|
|
if (visibleColumnIndexes.length === 1) return;
|
|
const newVisible = visibleColumnIndexes.filter(i => i !== index);
|
|
newVisible.sort((a, b) => a - b);
|
|
setVisibleColumnIndexes(newVisible);
|
|
if (currentSortIndex === index) {
|
|
setSortBy(0);
|
|
setSortReversed(false);
|
|
} else if (index <= currentSortIndex) {
|
|
setSortBy(sortBy - 1); // Keep the current sort
|
|
}
|
|
} else {
|
|
const newVisible = [index, ...visibleColumnIndexes];
|
|
newVisible.sort((a, b) => a - b);
|
|
setVisibleColumnIndexes(newVisible);
|
|
if (currentSortIndex >= index) {
|
|
setSortBy(sortBy + 1); // Keep the current sort
|
|
}
|
|
}
|
|
}, [visibleColumnIndexes, setVisibleColumnIndexes, sortBy, setSortBy, setSortReversed]);
|
|
const visibleColumns = visibleColumnIndexes.map(i => columns[i]);
|
|
const invisibleColumns = columns.filter((c, i) => !visibleColumnIndexes.includes(i));
|
|
|
|
const [selectedPaginationCount, setSelectedPaginationCount] = useState(0)
|
|
const paginationCountOptions = ["10", "25", "100"];
|
|
const paginationCount = Number(paginationCountOptions[selectedPaginationCount]);
|
|
|
|
const sortingFunction = (a, b) => {
|
|
const key = visibleColumns[sortBy].data._ || visibleColumns[sortBy].data;
|
|
const valA = a[key];
|
|
const valB = b[key];
|
|
if (valA === undefined && valB === undefined) return 0;
|
|
if (valA === undefined) return sortReversed ? 1 : -1;
|
|
if (valB === undefined) return sortReversed ? 1 : -1;
|
|
const isNumberA = typeof valA === 'number' || !isNaN(valA);
|
|
const isNumberB = typeof valB === 'number' || !isNaN(valB);
|
|
if (isNumberA && isNumberB) {
|
|
return sortReversed ? valA - valB : valB - valA;
|
|
}
|
|
return sortReversed ? valB.localeCompare(valA) : valA.localeCompare(valB);
|
|
}
|
|
const changeSort = index => {
|
|
setSortBy(index);
|
|
setSortReversed(index === sortBy && !sortReversed);
|
|
}
|
|
|
|
const keys = visibleColumns.flatMap(column => [column.data._ || column.data, column.data.display]);
|
|
const [filter, setFilter] = useState('');
|
|
const filterWords = filter.split(' ').filter(word => word);
|
|
const matchingData = options.data.filter(row => {
|
|
if (!filter) return true;
|
|
|
|
return Boolean(keys.some(key => filterWords.some(word => String(row[key]).toLowerCase().includes(word))));
|
|
});
|
|
matchingData.sort(sortingFunction);
|
|
|
|
const [expandedRows, setExpandedRows] = useState([]);
|
|
const toggleRow = useCallback(i => {
|
|
if (expandedRows.includes(i)) {
|
|
setExpandedRows(expandedRows.filter(item => item !== i));
|
|
} else {
|
|
setExpandedRows([...expandedRows, i]);
|
|
}
|
|
}, [setExpandedRows, expandedRows]);
|
|
useEffect(() => {
|
|
if (visibleColumnIndexes.length === columns.length) {
|
|
setExpandedRows([]);
|
|
}
|
|
}, [visibleColumnIndexes, columns, setExpandedRows])
|
|
|
|
const [page, setPage] = useState(0);
|
|
const maxPage = Math.ceil(matchingData.length / paginationCount);
|
|
|
|
const rows = matchingData.slice(page * paginationCount, Math.min(page * paginationCount + paginationCount, options.data.length));
|
|
|
|
useEffect(() => {
|
|
setPage(0);
|
|
}, [filter, paginationCount, sortBy, sortReversed]);
|
|
|
|
if (!rowKeyFunction) {
|
|
rowKeyFunction = (row, column) => {
|
|
return JSON.stringify(row) + "-" + JSON.stringify(column?.data);
|
|
}
|
|
}
|
|
|
|
// Overflow protection
|
|
const onResize = useCallback(() => {
|
|
const container = document.getElementById(id + "-container");
|
|
const table = document.getElementById(id);
|
|
if (!container || !table) return;
|
|
const overflowing = table.scrollWidth > container.clientWidth;
|
|
if (overflowing) toggleColumn(visibleColumnIndexes[visibleColumnIndexes.length - 1]);
|
|
}, [id, toggleColumn, visibleColumnIndexes]);
|
|
useEffect(() => {
|
|
window.addEventListener('resize', onResize);
|
|
return () => window.removeEventListener('resize', onResize);
|
|
}, [onResize]);
|
|
|
|
const someColumnsHidden = columns.length !== visibleColumns.length;
|
|
|
|
return (
|
|
<div id={id + "-container"}>
|
|
<div className={"float-start"}>
|
|
<InputGroup className={"dataTables_length"}>
|
|
<label className={"input-group-text"}>{t('html.label.table.showPerPage')}</label>
|
|
<Select options={paginationCountOptions} selectedIndex={selectedPaginationCount}
|
|
setSelectedIndex={setSelectedPaginationCount}/>
|
|
</InputGroup>
|
|
</div>
|
|
<div className={"float-end"}>
|
|
<SearchField className={"dataTables_filter"} value={filter} setValue={setFilter}/>
|
|
</div>
|
|
{columns.length > 2 && <div className={"float-start dataTables_columns"}>
|
|
<VisibleColumnsSelector columns={columns} visibleColumnIndexes={visibleColumnIndexes}
|
|
toggleColumn={toggleColumn}/>
|
|
</div>}
|
|
<div className={"float-end dataTables_columns"}>
|
|
<ExportMenu matchingData={matchingData} columns={columns}/>
|
|
</div>
|
|
<table id={id}
|
|
className={"datatable table table-bordered table-striped" + (nightModeEnabled ? " table-dark" : '')}
|
|
style={{width: "100%"}}>
|
|
<thead id={id + '-head'} className={colorClass}>
|
|
<tr>
|
|
{visibleColumns.map((column, i) => <th key={JSON.stringify(column.data)}>
|
|
<button onClick={() => changeSort(i)}>
|
|
{column.title} <span className={"float-end"}>
|
|
<SortIcon selected={i === sortBy}
|
|
reversed={sortReversed}/>
|
|
</span>
|
|
</button>
|
|
</th>)}
|
|
</tr>
|
|
</thead>
|
|
<tbody id={id + '-body'}>
|
|
{!rows.length && <tr>
|
|
{visibleColumns.map((column, i) => <td key={"col-" + rowKeyFunction(0, column)}>
|
|
{i === 0 && t('html.label.noDataToDisplay')}
|
|
</td>)}
|
|
</tr>}
|
|
{rows.map(row => <React.Fragment key={"frag-" + rowKeyFunction(row, null)}>
|
|
<tr key={"row-" + rowKeyFunction(row, null)}>
|
|
{visibleColumns.map((column, i) => {
|
|
if (column.data._ !== undefined) {
|
|
return <td key={"col-" + rowKeyFunction(row, column)}>
|
|
{i === 0 && someColumnsHidden &&
|
|
<button style={{paddingRight: "0.5rem"}}
|
|
onClick={() => toggleRow(rowKeyFunction(row, null))}>
|
|
<FontAwesomeIcon
|
|
icon={expandedRows.includes(rowKeyFunction(row, null)) ? faMinusSquare : faPlusSquare}
|
|
className={"col-green"}/>
|
|
</button>}
|
|
{row[column.data.display]}
|
|
</td>
|
|
} else {
|
|
return <td key={"col-" + rowKeyFunction(row, column)}>
|
|
{i === 0 && someColumnsHidden &&
|
|
<button style={{paddingRight: "0.5rem"}}
|
|
onClick={() => toggleRow(rowKeyFunction(row, null))}>
|
|
<FontAwesomeIcon
|
|
icon={expandedRows.includes(rowKeyFunction(row, null)) ? faMinusSquare : faPlusSquare}
|
|
className={"col-green"}/>
|
|
</button>}
|
|
{row[column.data]}
|
|
</td>
|
|
}
|
|
})}
|
|
</tr>
|
|
{expandedRows.includes(rowKeyFunction(row, null)) &&
|
|
<tr key={"hidden-row" + rowKeyFunction(row, null)}>
|
|
<td colSpan={visibleColumns.length}>
|
|
{invisibleColumns.map(column => {
|
|
if (column.data._ !== undefined) {
|
|
return <p key={"p-" + rowKeyFunction(row, column)}>
|
|
<b>{column.title}:</b> {row[column.data.display]}</p>
|
|
} else {
|
|
return <p key={"p-" + rowKeyFunction(row, column)}>
|
|
<b>{column.title}:</b> {row[column.data]}</p>
|
|
}
|
|
})}
|
|
</td>
|
|
</tr>}
|
|
</React.Fragment>)}
|
|
</tbody>
|
|
</table>
|
|
<p className={"dataTables_info float-start"}
|
|
style={{maxWidth: "40%", textOverflow: "ellipsis", whiteSpace: "nowrap"}}>
|
|
<Trans i18nKey={"html.label.table.showNofM"}
|
|
defaults={"Showing {{n}} of {{m}} entries"}
|
|
values={{
|
|
n: `${page * paginationCount + 1}-${page * paginationCount + rows.length}`,
|
|
m: matchingData.length
|
|
}}/>
|
|
</p>
|
|
<div className={"float-end"} style={{maxWidth: "60%"}}>
|
|
<Pagination page={page} setPage={setPage} maxPage={maxPage}/>
|
|
</div>
|
|
</div>
|
|
)
|
|
};
|
|
|
|
export default DataTablesTable |