Files
qqq-frontend-material-dashb…/src/qqq/pages/entity-list/index.tsx

726 lines
25 KiB
TypeScript

/**
=========================================================
* Material Dashboard 2 PRO React TS - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/material-dashboard-2-pro-react-ts
* Copyright 2022 Creative Tim (https://www.creative-tim.com)
Coded by www.creative-tim.com
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*/
/* eslint-disable react/no-unstable-nested-components */
import React, {useEffect, useReducer, useState} from "react";
import {useParams, useSearchParams} from "react-router-dom";
// @mui material components
import Card from "@mui/material/Card";
import Icon from "@mui/material/Icon";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Link from "@mui/material/Link";
import {Alert, tableFooterClasses} from "@mui/material";
import {
DataGridPro,
GridCallbackDetails,
GridColDef,
GridColumnOrderChangeParams,
GridColumnVisibilityModel,
GridFilterModel,
GridRowId,
GridRowParams,
GridRowsProp,
GridSelectionModel,
GridSortItem,
GridSortModel,
GridToolbarColumnsButton,
GridToolbarContainer,
GridToolbarDensitySelector,
GridToolbarExportContainer,
GridToolbarFilterButton,
GridExportMenuItemProps,
} from "@mui/x-data-grid-pro";
// Material Dashboard 2 PRO React TS components
import DashboardLayout from "examples/LayoutContainers/DashboardLayout";
import DashboardNavbar from "examples/Navbars/DashboardNavbar";
import MDBox from "components/MDBox";
import MDButton from "components/MDButton";
import MDAlert from "components/MDAlert";
// QQQ
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import QClient from "qqq/utils/QClient";
import Navbar from "qqq/components/Navbar";
import Button from "@mui/material/Button";
import Footer from "../../components/Footer";
import QProcessUtils from "../../utils/QProcessUtils";
import "./styles.css";
const COLUMN_VISIBILITY_LOCAL_STORAGE_KEY_ROOT = "qqq.columnVisibility";
const COLUMN_SORT_LOCAL_STORAGE_KEY_ROOT = "qqq.columnSort";
// Declaring props types for DefaultCell
interface Props
{
table?: QTableMetaData;
}
function EntityList({table}: Props): JSX.Element
{
const tableNameParam = useParams().tableName;
const tableName = table === null ? tableNameParam : table.name;
const [searchParams] = useSearchParams();
////////////////////////////////////////////
// look for defaults in the local storage //
////////////////////////////////////////////
const sortLocalStorageKey = `${COLUMN_SORT_LOCAL_STORAGE_KEY_ROOT}.${tableName}`;
const columnVisibilityLocalStorageKey = `${COLUMN_VISIBILITY_LOCAL_STORAGE_KEY_ROOT}.${tableName}`;
let defaultSort = [] as GridSortItem[];
let defaultVisibility = {};
if (localStorage.getItem(sortLocalStorageKey))
{
defaultSort = JSON.parse(localStorage.getItem(sortLocalStorageKey));
}
if (localStorage.getItem(columnVisibilityLocalStorageKey))
{
defaultVisibility = JSON.parse(localStorage.getItem(columnVisibilityLocalStorageKey));
}
const [buttonText, setButtonText] = useState("");
const [tableState, setTableState] = useState("");
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
const [, setFiltersMenu] = useState(null);
const [actionsMenu, setActionsMenu] = useState(null);
const [tableProcesses, setTableProcesses] = useState([] as QProcessMetaData[]);
const [pageNumber, setPageNumber] = useState(0);
const [totalRecords, setTotalRecords] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [selectedIds, setSelectedIds] = useState([] as string[]);
const [selectFullFilterState, setSelectFullFilterState] = useState("n/a" as "n/a" | "checked" | "filter");
const [columns, setColumns] = useState([] as GridColDef[]);
const [rows, setRows] = useState([] as GridRowsProp[]);
const [loading, setLoading] = useState(true);
const [filterModel, setFilterModel] = useState(null as GridFilterModel);
const [alertContent, setAlertContent] = useState("");
const [tableLabel, setTableLabel] = useState("");
const [columnSortModel, setColumnSortModel] = useState(defaultSort);
const [columnVisibilityModel, setColumnVisibilityModel] = useState(defaultVisibility);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const openActionsMenu = (event: any) => setActionsMenu(event.currentTarget);
const closeActionsMenu = () => setActionsMenu(null);
const translateCriteriaOperator = (operator: string) =>
{
switch (operator)
{
case "contains":
return QCriteriaOperator.CONTAINS;
case "startsWith":
return QCriteriaOperator.STARTS_WITH;
case "endsWith":
return QCriteriaOperator.ENDS_WITH;
case "is":
case "equals":
case "=":
return QCriteriaOperator.EQUALS;
case "isNot":
case "!=":
return QCriteriaOperator.NOT_EQUALS;
case "after":
case ">":
return QCriteriaOperator.GREATER_THAN;
case "onOrAfter":
case ">=":
return QCriteriaOperator.GREATER_THAN_OR_EQUALS;
case "before":
case "<":
return QCriteriaOperator.LESS_THAN;
case "onOrBefore":
case "<=":
return QCriteriaOperator.LESS_THAN_OR_EQUALS;
case "isEmpty":
return QCriteriaOperator.IS_BLANK;
case "isNotEmpty":
return QCriteriaOperator.IS_NOT_BLANK;
// case "is any of":
// TODO: handle this case
default:
return QCriteriaOperator.EQUALS;
}
};
const buildQFilter = () =>
{
const qFilter = new QQueryFilter();
if (columnSortModel)
{
columnSortModel.forEach((gridSortItem) =>
{
qFilter.addOrderBy(new QFilterOrderBy(gridSortItem.field, gridSortItem.sort === "asc"));
});
}
if (filterModel)
{
filterModel.items.forEach((item) =>
{
const operator = translateCriteriaOperator(item.operatorValue);
let criteria = new QFilterCriteria(item.columnField, operator, [item.value]);
if (operator === QCriteriaOperator.IS_BLANK || operator === QCriteriaOperator.IS_NOT_BLANK)
{
criteria = new QFilterCriteria(item.columnField, translateCriteriaOperator(item.operatorValue), null);
}
qFilter.addCriteria(criteria);
});
}
return qFilter;
};
const updateTable = () =>
{
(async () =>
{
const newTableMetaData = await QClient.loadTableMetaData(tableName);
setTableMetaData(newTableMetaData);
if (columnSortModel.length === 0)
{
columnSortModel.push({
field: newTableMetaData.primaryKeyField,
sort: "desc",
});
setColumnSortModel(columnSortModel);
}
const qFilter = buildQFilter();
const count = await QClient.count(tableName, qFilter);
setTotalRecords(count);
setButtonText(`new ${newTableMetaData.label}`);
setTableLabel(newTableMetaData.label);
const columns = [] as GridColDef[];
const results = await QClient.query(
tableName,
qFilter,
rowsPerPage,
pageNumber * rowsPerPage,
)
.catch((error) =>
{
if (error.message)
{
setAlertContent(error.message);
}
else
{
setAlertContent(error.response.data.error);
}
throw error;
});
const rows = [] as any[];
results.forEach((record) =>
{
rows.push(Object.fromEntries(record.values.entries()));
});
const sortedKeys = [...newTableMetaData.fields.keys()].sort();
sortedKeys.forEach((key) =>
{
const field = newTableMetaData.fields.get(key);
let columnType = "string";
switch (field.type)
{
case QFieldType.DECIMAL:
case QFieldType.INTEGER:
columnType = "number";
break;
case QFieldType.DATE:
columnType = "date";
break;
case QFieldType.DATE_TIME:
columnType = "dateTime";
break;
case QFieldType.BOOLEAN:
columnType = "boolean";
break;
default:
// noop
}
const column = {
field: field.name,
type: columnType,
headerName: field.label,
width: 200,
};
if (key === newTableMetaData.primaryKeyField)
{
column.width = 75;
columns.splice(0, 0, column);
}
else
{
columns.push(column);
}
});
setColumns(columns);
setRows(rows);
setLoading(false);
forceUpdate();
})();
};
const handlePageChange = (page: number) =>
{
setPageNumber(page);
};
const handleRowsPerPageChange = (size: number) =>
{
setRowsPerPage(size);
};
const handleRowClick = (params: GridRowParams) =>
{
document.location.href = `/${tableName}/${params.id}`;
};
const handleFilterChange = (filterModel: GridFilterModel) =>
{
setFilterModel(filterModel);
};
const selectionChanged = (selectionModel: GridSelectionModel, details: GridCallbackDetails) =>
{
const newSelectedIds: string[] = [];
selectionModel.forEach((value: GridRowId) =>
{
newSelectedIds.push(value as string);
});
setSelectedIds(newSelectedIds);
if (newSelectedIds.length === rowsPerPage)
{
setSelectFullFilterState("checked");
}
else
{
setSelectFullFilterState("n/a");
}
};
const handleColumnVisibilityChange = (columnVisibilityModel: GridColumnVisibilityModel) =>
{
setColumnVisibilityModel(columnVisibilityModel);
if (columnVisibilityLocalStorageKey)
{
localStorage.setItem(
columnVisibilityLocalStorageKey,
JSON.stringify(columnVisibilityModel),
);
}
};
const handleColumnOrderChange = (columnOrderChangeParams: GridColumnOrderChangeParams) =>
{
// TODO: make local storaged
console.log(JSON.stringify(columns));
console.log(columnOrderChangeParams);
};
const handleSortChange = (gridSort: GridSortModel) =>
{
if (gridSort && gridSort.length > 0)
{
setColumnSortModel(gridSort);
localStorage.setItem(sortLocalStorageKey, JSON.stringify(gridSort));
}
};
if (tableName !== tableState)
{
(async () =>
{
setTableState(tableName);
setFilterModel(null);
setFiltersMenu(null);
const metaData = await QClient.loadMetaData();
setTableProcesses(QProcessUtils.getProcessesForTable(metaData, tableName));
// reset rows to trigger rerender
setRows([]);
})();
}
interface QExportMenuItemProps extends GridExportMenuItemProps<{}>
{
format: string;
}
function ExportMenuItem(props: QExportMenuItemProps)
{
const {format, hideMenu} = props;
return (
<MenuItem
disabled={totalRecords === 0}
onClick={() =>
{
///////////////////////////////////////////////////////////////////////////////
// build the list of visible fields. note, not doing them in-order (in case //
// the user did drag & drop), because column order model isn't right yet //
// so just doing them to match columns (which were pKey, then sorted) //
///////////////////////////////////////////////////////////////////////////////
const visibleFields: string[] = [];
columns.forEach((gridColumn) =>
{
const fieldName = gridColumn.field;
// @ts-ignore
if (columnVisibilityModel[fieldName] !== false)
{
visibleFields.push(fieldName);
}
});
///////////////////////
// zero-pad function //
///////////////////////
const zp = (value: number): string => (value < 10 ? `0${value}` : `${value}`);
//////////////////////////////////////
// construct the url for the export //
//////////////////////////////////////
const d = new Date();
const dateString = `${d.getFullYear()}-${zp(d.getMonth())}-${zp(d.getDate())} ${zp(d.getHours())}${zp(d.getMinutes())}`;
const filename = `${tableMetaData.label} Export ${dateString}.${format}`;
const url = `/data/${tableMetaData.name}/export/${filename}?filter=${encodeURIComponent(JSON.stringify(buildQFilter()))}&fields=${visibleFields.join(",")}`;
//////////////////////////////////////////////////////////////////////////////////////
// open a window (tab) with a little page that says the file is being generated. //
// then have that page load the url for the export. //
// If there's an error, it'll appear in that window. else, the file will download. //
//////////////////////////////////////////////////////////////////////////////////////
const exportWindow = window.open("", "_blank");
exportWindow.document.write(`<html lang="en">
<head>
<style>
* { font-family: "Roboto","Helvetica","Arial",sans-serif; }
</style>
<title>${filename}</title>
<script>
setTimeout(() =>
{
window.location.href="${url}";
}, 1);
</script>
</head>
<body>Generating file <u>${filename}</u> with ${totalRecords.toLocaleString()} records...</body>
</html>`);
///////////////////////////////////////////
// Hide the export menu after the export //
///////////////////////////////////////////
hideMenu?.();
}}
>
Export
{` ${format.toUpperCase()}`}
</MenuItem>
);
}
function getNoOfSelectedRecords()
{
if (selectFullFilterState === "filter")
{
return (totalRecords);
}
return (selectedIds.length);
}
function getRecordsQueryString()
{
if (selectFullFilterState === "filter")
{
return `?recordsParam=filterJSON&filterJSON=${JSON.stringify(buildQFilter())}`;
}
if (selectedIds.length > 0)
{
return `?recordsParam=recordIds&recordIds=${selectedIds.join(",")}`;
}
return "";
}
const bulkLoadClicked = () =>
{
document.location.href = `/processes/${tableName}.bulkInsert`;
};
const bulkEditClicked = () =>
{
if (getNoOfSelectedRecords() === 0)
{
setAlertContent("No records were selected to Bulk Edit.");
return;
}
document.location.href = `/processes/${tableName}.bulkEdit${getRecordsQueryString()}`;
};
const bulkDeleteClicked = () =>
{
if (getNoOfSelectedRecords() === 0)
{
setAlertContent("No records were selected to Bulk Delete.");
return;
}
document.location.href = `/processes/${tableName}.bulkDelete${getRecordsQueryString()}`;
};
function CustomToolbar()
{
const [bulkActionsMenuAnchor, setBulkActionsMenuAnchor] = useState(null as HTMLElement);
const bulkActionsMenuOpen = Boolean(bulkActionsMenuAnchor);
const openBulkActionsMenu = (event: React.MouseEvent<HTMLElement>) =>
{
setBulkActionsMenuAnchor(event.currentTarget);
};
const closeBulkActionsMenu = () =>
{
setBulkActionsMenuAnchor(null);
};
return (
<GridToolbarContainer>
<div>
<Button
id="refresh-button"
onClick={updateTable}
>
<Icon>refresh</Icon>
Refresh
</Button>
</div>
<GridToolbarColumnsButton />
<GridToolbarFilterButton />
<GridToolbarDensitySelector />
<GridToolbarExportContainer>
<ExportMenuItem format="csv" />
<ExportMenuItem format="xlsx" />
</GridToolbarExportContainer>
<div>
<Button
id="bulk-actions-button"
onClick={openBulkActionsMenu}
aria-controls={bulkActionsMenuOpen ? "basic-menu" : undefined}
aria-haspopup="true"
aria-expanded={bulkActionsMenuOpen ? "true" : undefined}
>
<Icon>fact_check</Icon>
Bulk Actions
</Button>
<Menu
id="bulk-actions-menu"
open={bulkActionsMenuOpen}
anchorEl={bulkActionsMenuAnchor}
onClose={closeBulkActionsMenu}
MenuListProps={{"aria-labelledby": "bulk-actions-button"}}
>
<MenuItem onClick={bulkLoadClicked}>Bulk Load</MenuItem>
<MenuItem onClick={bulkEditClicked}>Bulk Edit</MenuItem>
<MenuItem onClick={bulkDeleteClicked}>Bulk Delete</MenuItem>
</Menu>
</div>
<div>
{
selectFullFilterState === "checked" && (
<div className="selectionTool">
The
<strong>{` ${selectedIds.length.toLocaleString()} `}</strong>
records on this page are selected.
<button
type="button"
onClick={() => setSelectFullFilterState("filter")}
className="MuiButton-root MuiButton-text MuiButton-textPrimary MuiButton-sizeSmall MuiButton-textSizeSmall MuiButtonBase-root css-knwngq-MuiButtonBase-root-MuiButton-root"
>
Select all
{` ${totalRecords.toLocaleString()} `}
records matching this query
</button>
</div>
)
}
{
selectFullFilterState === "filter" && (
<div className="selectionTool">
All
<strong>{` ${totalRecords.toLocaleString()} `}</strong>
records matching this query are selected.
<button
type="button"
onClick={() => setSelectFullFilterState("checked")}
className="MuiButton-root MuiButton-text MuiButton-textPrimary MuiButton-sizeSmall MuiButton-textSizeSmall MuiButtonBase-root css-knwngq-MuiButtonBase-root-MuiButton-root"
>
Select the
{` ${selectedIds.length.toLocaleString()} `}
records on this page
</button>
</div>
)
}
</div>
</GridToolbarContainer>
);
}
const renderActionsMenu = (
<Menu
anchorEl={actionsMenu}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
open={Boolean(actionsMenu)}
onClose={closeActionsMenu}
keepMounted
>
{tableProcesses.map((process) => (
<MenuItem key={process.name}>
<Link href={`/processes/${process.name}${getRecordsQueryString()}`}>{process.label}</Link>
</MenuItem>
))}
</Menu>
);
useEffect(() =>
{
updateTable();
}, [pageNumber, rowsPerPage, tableState, columnSortModel, filterModel]);
return (
<DashboardLayout>
<Navbar />
<MDBox my={3}>
{alertContent ? (
<MDBox mb={3}>
<Alert
severity="error"
onClose={() =>
{
setAlertContent(null);
}}
>
{alertContent}
</Alert>
</MDBox>
) : (
""
)}
{
(tableLabel && searchParams.get("deleteSuccess")) ? (
<MDAlert color="success" dismissible>
{`${tableLabel} successfully deleted`}
</MDAlert>
) : ("")
}
<MDBox display="flex" justifyContent="space-between" alignItems="flex-start" mb={2}>
{buttonText ? (
<Link href={`/${tableName}/create`}>
<MDButton variant="gradient" color="info">
{
buttonText
}
</MDButton>
</Link>
) : (
""
)}
<MDBox display="flex">
{tableProcesses.length > 0 && (
<MDButton
variant={actionsMenu ? "contained" : "outlined"}
color="dark"
onClick={openActionsMenu}
>
actions&nbsp;
<Icon>keyboard_arrow_down</Icon>
</MDButton>
)}
{renderActionsMenu}
</MDBox>
</MDBox>
<Card>
<MDBox height="100%">
<DataGridPro
components={{Toolbar: CustomToolbar}}
pagination
paginationMode="server"
sortingMode="server"
filterMode="server"
page={pageNumber}
checkboxSelection
disableSelectionOnClick
autoHeight
rows={rows}
columns={columns}
rowBuffer={10}
rowCount={totalRecords}
pageSize={rowsPerPage}
rowsPerPageOptions={[10, 25, 50]}
onPageSizeChange={handleRowsPerPageChange}
onPageChange={handlePageChange}
onRowClick={handleRowClick}
density="compact"
loading={loading}
onFilterModelChange={handleFilterChange}
columnVisibilityModel={columnVisibilityModel}
onColumnVisibilityModelChange={handleColumnVisibilityChange}
onColumnOrderChange={handleColumnOrderChange}
onSelectionModelChange={selectionChanged}
onSortModelChange={handleSortChange}
sortingOrder={["asc", "desc"]}
sortModel={columnSortModel}
getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")}
/>
</MDBox>
</Card>
</MDBox>
<Footer />
</DashboardLayout>
);
}
EntityList.defaultProps = {
table: null,
};
export default EntityList;