mirror of
https://github.com/Kingsrook/qqq-frontend-material-dashboard.git
synced 2025-07-18 05:10:45 +00:00
- New Composite & Block widget constructs. - Option for a parent widget's label to be the app home page's label - Updates to table-widget handling of fixed footer (to expand and stay fixed) - Option for widgets to have CSV Data that can be exported differently from just the data "in" the widget. -- This included changing the default value for showExportButton from true to false
558 lines
21 KiB
TypeScript
558 lines
21 KiB
TypeScript
/* QQQ - Low-code Application Framework for Engineers.
|
|
* Copyright (C) 2021-2022. Kingsrook, LLC
|
|
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
|
* contact@kingsrook.com
|
|
* https://github.com/Kingsrook/
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as
|
|
* published by the Free Software Foundation, either version 3 of the
|
|
* License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
|
|
import {tooltipClasses, TooltipProps} from "@mui/material";
|
|
import Autocomplete from "@mui/material/Autocomplete";
|
|
import Box from "@mui/material/Box";
|
|
import Icon from "@mui/material/Icon";
|
|
import {styled} from "@mui/material/styles";
|
|
import Table from "@mui/material/Table";
|
|
import TableBody from "@mui/material/TableBody";
|
|
import TableContainer from "@mui/material/TableContainer";
|
|
import TableRow from "@mui/material/TableRow";
|
|
import Tooltip from "@mui/material/Tooltip";
|
|
import parse from "html-react-parser";
|
|
import {useEffect, useMemo, useState} from "react";
|
|
import {useAsyncDebounce, useGlobalFilter, usePagination, useSortBy, useTable, useExpanded} from "react-table";
|
|
import colors from "qqq/assets/theme/base/colors";
|
|
import MDInput from "qqq/components/legacy/MDInput";
|
|
import MDPagination from "qqq/components/legacy/MDPagination";
|
|
import MDTypography from "qqq/components/legacy/MDTypography";
|
|
import CompositeWidget from "qqq/components/widgets/CompositeWidget";
|
|
import DataTableBodyCell from "qqq/components/widgets/tables/cells/DataTableBodyCell";
|
|
import DataTableHeadCell from "qqq/components/widgets/tables/cells/DataTableHeadCell";
|
|
import DefaultCell from "qqq/components/widgets/tables/cells/DefaultCell";
|
|
import ImageCell from "qqq/components/widgets/tables/cells/ImageCell";
|
|
import {TableDataInput} from "qqq/components/widgets/tables/TableCard";
|
|
import WidgetBlock from "qqq/components/widgets/WidgetBlock";
|
|
|
|
interface Props
|
|
{
|
|
entriesPerPage?: number;
|
|
entriesPerPageOptions?: number[];
|
|
canSearch?: boolean;
|
|
showTotalEntries?: boolean;
|
|
hidePaginationDropdown?: boolean;
|
|
fixedStickyLastRow?: boolean;
|
|
fixedHeight?: number;
|
|
table: TableDataInput;
|
|
pagination?: {
|
|
variant: "contained" | "gradient";
|
|
color: "primary" | "secondary" | "info" | "success" | "warning" | "error" | "dark" | "light";
|
|
};
|
|
isSorted?: boolean;
|
|
noEndBorder?: boolean;
|
|
widgetMetaData: QWidgetMetaData;
|
|
}
|
|
|
|
DataTable.defaultProps = {
|
|
entriesPerPage: 10,
|
|
entriesPerPageOptions: ["5", "10", "15", "20", "25"],
|
|
canSearch: false,
|
|
showTotalEntries: true,
|
|
fixedStickyLastRow: false,
|
|
fixedHeight: null,
|
|
pagination: {variant: "gradient", color: "info"},
|
|
isSorted: true,
|
|
noEndBorder: false,
|
|
};
|
|
|
|
const NoMaxWidthTooltip = styled(({className, ...props}: TooltipProps) => (
|
|
<Tooltip {...props} classes={{popper: className}} />
|
|
))({
|
|
[`& .${tooltipClasses.tooltip}`]: {
|
|
maxWidth: "none",
|
|
textAlign: "left"
|
|
},
|
|
});
|
|
|
|
function DataTable({
|
|
entriesPerPage,
|
|
entriesPerPageOptions,
|
|
hidePaginationDropdown,
|
|
canSearch,
|
|
showTotalEntries,
|
|
fixedStickyLastRow,
|
|
fixedHeight,
|
|
table,
|
|
pagination,
|
|
isSorted,
|
|
noEndBorder,
|
|
widgetMetaData
|
|
}: Props): JSX.Element
|
|
{
|
|
let defaultValue: any;
|
|
let entries: any[];
|
|
|
|
defaultValue = (entriesPerPage) ? entriesPerPage : "10";
|
|
entries = entriesPerPageOptions ? entriesPerPageOptions : ["10", "25", "50", "100"];
|
|
|
|
let widths = [];
|
|
for(let i = 0; i<table.columns.length; i++)
|
|
{
|
|
const column = table.columns[i];
|
|
if(column.type !== "hidden")
|
|
{
|
|
widths.push(table.columns[i].width ?? "1fr");
|
|
}
|
|
}
|
|
|
|
let showExpandColumn = false;
|
|
if(table.rows)
|
|
{
|
|
for (let i = 0; i < table.rows.length; i++)
|
|
{
|
|
if (table.rows[i].subRows)
|
|
{
|
|
showExpandColumn = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const columnsToMemo = [...table.columns];
|
|
if(showExpandColumn)
|
|
{
|
|
widths.push("60px");
|
|
columnsToMemo.push(
|
|
{
|
|
///////////////////////////////
|
|
// Build our expander column //
|
|
///////////////////////////////
|
|
id: "__expander",
|
|
width: 60,
|
|
|
|
////////////////////////////////////////////////
|
|
// use this block if we want to do expand-all //
|
|
////////////////////////////////////////////////
|
|
// @ts-ignore
|
|
// header: ({getToggleAllRowsExpandedProps, isAllRowsExpanded}) => (
|
|
// <span {...getToggleAllRowsExpandedProps()}>
|
|
// {isAllRowsExpanded ? "yes" : "no"}
|
|
// </span>
|
|
// ),
|
|
header: () => (<span />),
|
|
|
|
// @ts-ignore
|
|
cell: ({row}) =>
|
|
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
// Use the row.canExpand and row.getToggleRowExpandedProps prop getter to build the toggle for expanding a row //
|
|
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
row.canExpand ? (
|
|
<span
|
|
{...row.getToggleRowExpandedProps({
|
|
//////////////////////////////////////////////////////////////////////////////////////////
|
|
// We could use the row.depth property and paddingLeft to indicate the depth of the row //
|
|
//////////////////////////////////////////////////////////////////////////////////////////
|
|
// style: {paddingLeft: `${row.depth * 2}rem`,},
|
|
})}
|
|
>
|
|
{/* float this icon to keep it "out of the flow" - in other words, to keep it from making the row taller than it otherwise would be... */}
|
|
<Icon fontSize="medium" sx={{float: "left"}}>{row.isExpanded ? "expand_less" : "chevron_right"}</Icon>
|
|
</span>
|
|
) : null,
|
|
},
|
|
);
|
|
}
|
|
|
|
const columns = useMemo<any>(() => columnsToMemo, [table]);
|
|
const data = useMemo<any>(() => table.rows, [table]);
|
|
const gridTemplateColumns = widths.join(" ");
|
|
|
|
if (!columns || !data)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
const tableInstance = useTable(
|
|
{columns, data, initialState: {pageIndex: 0}},
|
|
useGlobalFilter,
|
|
useSortBy,
|
|
useExpanded,
|
|
usePagination
|
|
);
|
|
|
|
const {
|
|
getTableProps,
|
|
getTableBodyProps,
|
|
headerGroups,
|
|
prepareRow,
|
|
rows,
|
|
page,
|
|
pageOptions,
|
|
canPreviousPage,
|
|
canNextPage,
|
|
gotoPage,
|
|
nextPage,
|
|
previousPage,
|
|
setPageSize,
|
|
setGlobalFilter,
|
|
state: {pageIndex, pageSize, globalFilter, expanded},
|
|
}: any = tableInstance;
|
|
|
|
// Set the default value for the entries per page when component mounts
|
|
useEffect(() => setPageSize(defaultValue || 10), [defaultValue]);
|
|
|
|
// Set the entries per page value based on the select value
|
|
const setEntriesPerPage = (value: any) => setPageSize(value);
|
|
|
|
// Render the pagination
|
|
const renderPagination = pageOptions.map((option: any) => (
|
|
<MDPagination
|
|
item
|
|
key={option}
|
|
onClick={() => gotoPage(Number(option))}
|
|
active={pageIndex === option}
|
|
>
|
|
{option + 1}
|
|
</MDPagination>
|
|
));
|
|
|
|
// Handler for the input to set the pagination index
|
|
const handleInputPagination = ({target: {value}}: any) =>
|
|
value > pageOptions.length || value < 0 ? gotoPage(0) : gotoPage(Number(value));
|
|
|
|
// Customized page options starting from 1
|
|
const customizedPageOptions = pageOptions.map((option: any) => option + 1);
|
|
|
|
// Setting value for the pagination input
|
|
const handleInputPaginationValue = ({target: value}: any) => gotoPage(Number(value.value - 1));
|
|
|
|
// Search input value state
|
|
const [search, setSearch] = useState(globalFilter);
|
|
|
|
// Search input state handle
|
|
const onSearchChange = useAsyncDebounce((value) =>
|
|
{
|
|
setGlobalFilter(value || undefined);
|
|
}, 100);
|
|
|
|
// A function that sets the sorted value for the table
|
|
const setSortedValue = (column: any) =>
|
|
{
|
|
let sortedValue;
|
|
|
|
if (isSorted && column.isSorted)
|
|
{
|
|
sortedValue = column.isSortedDesc ? "desc" : "asc";
|
|
}
|
|
else if (isSorted)
|
|
{
|
|
sortedValue = "none";
|
|
}
|
|
else
|
|
{
|
|
sortedValue = false;
|
|
}
|
|
|
|
return sortedValue;
|
|
};
|
|
|
|
// Setting the entries starting point
|
|
const entriesStart = pageIndex === 0 ? pageIndex + 1 : pageIndex * pageSize + 1;
|
|
|
|
// Setting the entries ending point
|
|
let entriesEnd;
|
|
|
|
if (pageIndex === 0)
|
|
{
|
|
entriesEnd = pageSize;
|
|
}
|
|
else if (pageIndex === pageOptions.length - 1)
|
|
{
|
|
entriesEnd = rows.length;
|
|
}
|
|
else
|
|
{
|
|
entriesEnd = pageSize * (pageIndex + 1);
|
|
}
|
|
|
|
let visibleFooterRows = 1;
|
|
if(expanded && expanded[`${table.rows.length-1}`])
|
|
{
|
|
//////////////////////////////////////////////////
|
|
// todo - should count how many are expanded... //
|
|
//////////////////////////////////////////////////
|
|
visibleFooterRows = 2;
|
|
}
|
|
|
|
function getTable(includeHead: boolean, rows: any, isFooter: boolean)
|
|
{
|
|
let boxStyle = {};
|
|
if(fixedStickyLastRow)
|
|
{
|
|
boxStyle = isFooter
|
|
? {borderTop: `0.0625rem solid ${colors.grayLines.main};`, backgroundColor: "#EEEEEE"}
|
|
: {flexGrow: 1, overflowY: "scroll", scrollbarGutter: "stable", marginBottom: "-1px"};
|
|
}
|
|
|
|
let innerBoxStyle = {};
|
|
if(fixedStickyLastRow && isFooter)
|
|
{
|
|
innerBoxStyle = {overflowY: "auto", scrollbarGutter: "stable"};
|
|
}
|
|
|
|
return <Box sx={boxStyle}><Box sx={innerBoxStyle}>
|
|
<Table {...getTableProps()}>
|
|
{
|
|
includeHead && (
|
|
<Box component="thead" sx={{position: "sticky", top: 0, background: "white", zIndex: 10}}>
|
|
{headerGroups.map((headerGroup: any, i: number) => (
|
|
<TableRow key={i} {...headerGroup.getHeaderGroupProps()} sx={{display: "grid", gridTemplateColumns: gridTemplateColumns}}>
|
|
{headerGroup.headers.map((column: any) => (
|
|
column.type !== "hidden" && (
|
|
<DataTableHeadCell
|
|
key={i++}
|
|
{...column.getHeaderProps(isSorted && column.getSortByToggleProps())}
|
|
align={column.align ? column.align : "left"}
|
|
sorted={setSortedValue(column)}
|
|
>
|
|
{column.render("header")}
|
|
</DataTableHeadCell>
|
|
)
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</Box>
|
|
)
|
|
}
|
|
<TableBody {...getTableBodyProps()}>
|
|
{rows.map((row: any, key: any) =>
|
|
{
|
|
prepareRow(row);
|
|
|
|
let overrideNoEndBorder = false;
|
|
|
|
//////////////////////////////////////////////////////////////////////////////////
|
|
// don't do an end-border on nested rows - unless they're the last one in a set //
|
|
//////////////////////////////////////////////////////////////////////////////////
|
|
if(row.depth > 0)
|
|
{
|
|
overrideNoEndBorder = true;
|
|
if(key + 1 < rows.length && rows[key + 1].depth == 0)
|
|
{
|
|
overrideNoEndBorder = false;
|
|
}
|
|
}
|
|
|
|
///////////////////////////////////////
|
|
// don't do end-border on the footer //
|
|
///////////////////////////////////////
|
|
if(isFooter)
|
|
{
|
|
overrideNoEndBorder = true;
|
|
}
|
|
|
|
let background = "initial";
|
|
if(isFooter)
|
|
{
|
|
background = "#EEEEEE";
|
|
}
|
|
else if(row.depth > 0 || row.isExpanded)
|
|
{
|
|
background = "#FAFAFA";
|
|
}
|
|
|
|
return (
|
|
<TableRow sx={{verticalAlign: "top", display: "grid", gridTemplateColumns: gridTemplateColumns, background: background}} key={key} {...row.getRowProps()}>
|
|
{row.cells.map((cell: any) => (
|
|
cell.column.type !== "hidden" && (
|
|
<DataTableBodyCell
|
|
key={key}
|
|
noBorder={noEndBorder || overrideNoEndBorder || row.isExpanded}
|
|
depth={row.depth}
|
|
align={cell.column.align ? cell.column.align : "left"}
|
|
{...cell.getCellProps()}
|
|
>
|
|
{
|
|
cell.column.type === "default" && (
|
|
cell.value && "number" === typeof cell.value ? (
|
|
<DefaultCell isFooter={isFooter}>{cell.value.toLocaleString()}</DefaultCell>
|
|
) : (<DefaultCell isFooter={isFooter}>{cell.render("Cell")}</DefaultCell>)
|
|
)
|
|
}
|
|
{
|
|
cell.column.type === "htmlAndTooltip" && (
|
|
<DefaultCell isFooter={isFooter}>
|
|
<NoMaxWidthTooltip title={parse(row.values["tooltip"])}>
|
|
<Box>
|
|
{parse(cell.value)}
|
|
</Box>
|
|
</NoMaxWidthTooltip>
|
|
</DefaultCell>
|
|
)
|
|
}
|
|
{
|
|
cell.column.type === "html" && (
|
|
<DefaultCell isFooter={isFooter}>{parse(cell.value ?? "")}</DefaultCell>
|
|
)
|
|
}
|
|
{
|
|
cell.column.type === "composite" && (
|
|
<DefaultCell isFooter={isFooter}>
|
|
<CompositeWidget widgetMetaData={widgetMetaData} data={cell.value} />
|
|
</DefaultCell>
|
|
)
|
|
}
|
|
{
|
|
cell.column.type === "block" && (
|
|
<DefaultCell isFooter={isFooter}>
|
|
<WidgetBlock widgetMetaData={widgetMetaData} block={cell.value} />
|
|
</DefaultCell>
|
|
)
|
|
}
|
|
{
|
|
cell.column.type === "image" && row.values["imageTotal"] && (
|
|
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} total={row.values["imageTotal"].toLocaleString()} totalType={row.values["imageTotalType"]} />
|
|
)
|
|
}
|
|
{
|
|
cell.column.type === "image" && !row.values["imageTotal"] && (
|
|
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} />
|
|
)
|
|
}
|
|
{
|
|
(cell.column.id === "__expander") && cell.render("cell")
|
|
}
|
|
</DataTableBodyCell>
|
|
)
|
|
))}
|
|
</TableRow>
|
|
);
|
|
})}
|
|
|
|
</TableBody>
|
|
</Table>
|
|
</Box></Box>
|
|
}
|
|
|
|
return (
|
|
<TableContainer sx={{boxShadow: "none", height: fixedHeight ? `${fixedHeight}px` : "auto"}}>
|
|
{entriesPerPage && ((hidePaginationDropdown !== undefined && !hidePaginationDropdown) || canSearch) ? (
|
|
<Box display="flex" justifyContent="space-between" alignItems="center" p={3}>
|
|
{entriesPerPage && (hidePaginationDropdown === undefined || !hidePaginationDropdown) && (
|
|
<Box display="flex" alignItems="center">
|
|
<Autocomplete
|
|
disableClearable
|
|
value={pageSize.toString()}
|
|
options={entries}
|
|
onChange={(event, newValues: any) =>
|
|
{
|
|
if (typeof newValues === "string")
|
|
{
|
|
setEntriesPerPage(parseInt(newValues, 10));
|
|
}
|
|
else
|
|
{
|
|
setEntriesPerPage(parseInt(newValues[0], 10));
|
|
}
|
|
}}
|
|
size="small"
|
|
sx={{width: "5rem"}}
|
|
renderInput={(params) => <MDInput {...params} />}
|
|
/>
|
|
<MDTypography variant="caption" color="secondary">
|
|
entries per page
|
|
</MDTypography>
|
|
</Box>
|
|
)}
|
|
{canSearch && (
|
|
<Box width="12rem" ml="auto">
|
|
<MDInput
|
|
placeholder="Search..."
|
|
value={search}
|
|
size="small"
|
|
fullWidth
|
|
onChange={({currentTarget}: any) =>
|
|
{
|
|
setSearch(search);
|
|
onSearchChange(currentTarget.value);
|
|
}}
|
|
/>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
) : null}
|
|
|
|
<Box display="flex" justifyContent="space-between" flexDirection="column" height="100%">
|
|
{
|
|
fixedStickyLastRow ? (
|
|
<>
|
|
{getTable(true, page.slice(0, page.length - visibleFooterRows), false)}
|
|
{getTable(false, page.slice(page.length - visibleFooterRows), true)}
|
|
</>
|
|
) : getTable(true, page, false)
|
|
}
|
|
</Box>
|
|
|
|
<Box
|
|
display="flex"
|
|
flexDirection={{xs: "column", sm: "row"}}
|
|
justifyContent="space-between"
|
|
alignItems={{xs: "flex-start", sm: "center"}}
|
|
p={!showTotalEntries && pageOptions.length === 1 ? 0 : 3}
|
|
>
|
|
{showTotalEntries && (
|
|
<Box mb={{xs: 3, sm: 0}}>
|
|
<MDTypography variant="button" color="secondary" fontWeight="regular">
|
|
Showing {entriesStart} to {entriesEnd} of {rows.length} entries
|
|
</MDTypography>
|
|
</Box>
|
|
)}
|
|
{pageOptions.length > 1 && (
|
|
<MDPagination
|
|
variant={pagination.variant ? pagination.variant : "gradient"}
|
|
color={pagination.color ? pagination.color : "info"}
|
|
>
|
|
{canPreviousPage && (
|
|
<MDPagination item onClick={() => previousPage()}>
|
|
<Icon sx={{fontWeight: "bold"}}>chevron_left</Icon>
|
|
</MDPagination>
|
|
)}
|
|
{renderPagination.length > 6 ? (
|
|
<Box width="5rem" mx={1}>
|
|
<MDInput
|
|
inputProps={{type: "number", min: 1, max: customizedPageOptions.length}}
|
|
value={customizedPageOptions[pageIndex]}
|
|
onChange={(event: any) =>
|
|
{
|
|
handleInputPagination(event);
|
|
handleInputPaginationValue(event);
|
|
}}
|
|
/>
|
|
</Box>
|
|
) : (
|
|
renderPagination
|
|
)}
|
|
{canNextPage && (
|
|
<MDPagination item onClick={() => nextPage()}>
|
|
<Icon sx={{fontWeight: "bold"}}>chevron_right</Icon>
|
|
</MDPagination>
|
|
)}
|
|
</MDPagination>
|
|
)}
|
|
</Box>
|
|
</TableContainer>
|
|
);
|
|
}
|
|
|
|
export default DataTable;
|