SPRINT-20: checkpoint commit of saved filter frontends

This commit is contained in:
Tim Chamberlain
2023-02-01 14:10:43 -06:00
parent 0c8827cc57
commit d29fb32978
7 changed files with 1130 additions and 417 deletions

View File

@ -28,13 +28,17 @@ import MDButton from "qqq/components/legacy/MDButton";
// eslint-disable import/prefer-default-export
const standardWidth = "150px";
export const standardWidth = "150px";
export function QCreateNewButton(): JSX.Element
interface QCreateNewButtonProps
{
tablePath: string;
}
export function QCreateNewButton({tablePath}: QCreateNewButtonProps): JSX.Element
{
return (
<Box ml={3} mr={2} width={standardWidth}>
<Link to="create">
<Link to={`${tablePath}/create`}>
<MDButton variant="gradient" color="info" fullWidth startIcon={<Icon>add</Icon>}>
Create New
</MDButton>
@ -116,6 +120,23 @@ export function QActionsMenuButton({isOpen, onClickHandler}: QActionsMenuButtonP
);
}
export function QSavedFiltersMenuButton({isOpen, onClickHandler}: QActionsMenuButtonProps): JSX.Element
{
return (
<Box width={standardWidth} ml={1}>
<MDButton
variant={isOpen ? "contained" : "outlined"}
color="dark"
onClick={onClickHandler}
fullWidth
>
saved&nbsp;filters&nbsp;
<Icon>keyboard_arrow_down</Icon>
</MDButton>
</Box>
);
}
interface QCancelButtonProps
{
onClickHandler: any;

View File

@ -67,6 +67,11 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element
let accumulatedPath = "";
for (let i = 0; i < routes.length; i++)
{
if(routes[i] === "savedFilter")
{
continue;
}
accumulatedPath = `${accumulatedPath}/${routes[i]}`;
fullRoutes.push(accumulatedPath);
pageTitle = `${routeToLabel(routes[i])} | ${pageTitle}`;

View File

@ -0,0 +1,526 @@
/*
* 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 {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete";
import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {KeyboardArrowDown} from "@mui/icons-material";
import {Alert, ClickAwayListener, Grow, MenuList, Paper, Popper} from "@mui/material";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import ButtonGroup from "@mui/material/ButtonGroup";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import {GridFilterModel, GridSortItem} from "@mui/x-data-grid-pro";
import FormData from "form-data";
import React, {useEffect, useRef, useState} from "react";
import {useLocation, useNavigate} from "react-router-dom";
import {QCancelButton, QDeleteButton, QSaveButton, QSavedFiltersMenuButton} from "qqq/components/buttons/DefaultButtons";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
interface Props
{
qController: QController;
metaData: QInstance;
tableMetaData: QTableMetaData;
currentSavedFilter: QRecord;
filterModel?: GridFilterModel;
columnSortModel?: GridSortItem[];
filterOnChangeCallback?: (selectedSavedFilterId: number) => void;
}
function SavedFilters({qController, metaData, tableMetaData, currentSavedFilter, filterModel, columnSortModel, filterOnChangeCallback}: Props): JSX.Element
{
const navigate = useNavigate();
const [savedFilters, setSavedFilters] = useState([] as QRecord[]);
const [savedFiltersMenu, setSavedFiltersMenu] = useState(null);
const [savedFiltersHaveLoaded, setSavedFiltersHaveLoaded] = useState(false);
const [filterIsModified, setFilterIsModified] = useState(false);
const [saveFilterPopupOpen, setSaveFilterPopupOpen] = useState(false);
const [isSaveFilterAs, setIsSaveFilterAs] = useState(false);
const [isRenameFilter, setIsRenameFilter] = useState(false);
const [isDeleteFilter, setIsDeleteFilter] = useState(false);
const [savedFilterNameInputValue, setSavedFilterNameInputValue] = useState(null as string);
const [popupAlertContent, setPopupAlertContent] = useState("");
const anchorRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const [saveOptionsOpen, setSaveOptionsOpen] = useState(false);
const SAVE_OPTION = "Save Filter...";
const SAVE_AS_OPTION = "Create A New Filter From This Filter...";
const RENAME_OPTION = "Rename This Filter...";
const CLEAR_OPTION = "Clear This Filter";
const DELETE_OPTION = "Delete This Filter...";
const dropdownOptions = [SAVE_AS_OPTION, RENAME_OPTION, CLEAR_OPTION, DELETE_OPTION];
const openSavedFiltersMenu = (event: any) => setSavedFiltersMenu(event.currentTarget);
const closeSavedFiltersMenu = () => setSavedFiltersMenu(null);
//////////////////////////////////////////////////////////////////////////
// load filters on first run, then monitor location or metadata changes //
//////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
loadSavedFilters()
.then(() =>
{
if (currentSavedFilter != null)
{
let qFilter = FilterUtils.buildQFilterFromGridFilter(filterModel, columnSortModel);
setFilterIsModified(JSON.stringify(qFilter) !== currentSavedFilter.values.get("filterJson"));
}
setSavedFiltersHaveLoaded(true);
});
}, [location , tableMetaData, currentSavedFilter, filterModel, columnSortModel])
/*******************************************************************************
** make request to load all saved filters from backend
*******************************************************************************/
async function loadSavedFilters()
{
if (! tableMetaData)
{
return;
}
const formData = new FormData();
formData.append("tableName", tableMetaData.name);
let savedFilters = await makeSavedFilterRequest("querySavedFilter", formData);
setSavedFilters(savedFilters);
}
/*******************************************************************************
** fired when a saved record is clicked from the dropdown
*******************************************************************************/
const handleSavedFilterRecordOnClick = async (record: QRecord) =>
{
setSaveFilterPopupOpen(false);
closeSavedFiltersMenu();
filterOnChangeCallback(record.values.get("id"));
navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedFilter/${record.values.get("id")}`);
};
/*******************************************************************************
** fired when a save option is selected from the save... button/dropdown combo
*******************************************************************************/
const handleDropdownOptionClick = (optionName: string) =>
{
setSaveOptionsOpen(false);
setPopupAlertContent(null);
closeSavedFiltersMenu();
setSavedFilterNameInputValue(null);
setSaveFilterPopupOpen(true);
setIsSaveFilterAs(false);
setIsRenameFilter(false);
setIsDeleteFilter(false)
switch(optionName)
{
case SAVE_OPTION:
break;
case SAVE_AS_OPTION:
setIsSaveFilterAs(true);
break;
case CLEAR_OPTION:
setSaveFilterPopupOpen(false)
filterOnChangeCallback(null);
navigate(metaData.getTablePathByName(tableMetaData.name));
break;
case RENAME_OPTION:
setIsRenameFilter(true);
break;
case DELETE_OPTION:
setIsDeleteFilter(true)
break;
}
}
/*******************************************************************************
** fired when save or delete button saved on confirmation dialogs
*******************************************************************************/
async function handleFilterDialogButtonOnClick()
{
try
{
const formData = new FormData();
if (isDeleteFilter)
{
formData.append("id", currentSavedFilter.values.get("id"));
await makeSavedFilterRequest("deleteSavedFilter", formData);
await(async() =>
{
handleDropdownOptionClick(CLEAR_OPTION);
})();
}
else
{
formData.append("tableName", tableMetaData.name);
formData.append("filterJson", JSON.stringify(FilterUtils.buildQFilterFromGridFilter(filterModel, columnSortModel)));
if (isSaveFilterAs || isRenameFilter || currentSavedFilter == null)
{
formData.append("label", savedFilterNameInputValue);
if(currentSavedFilter != null && isRenameFilter)
{
formData.append("id", currentSavedFilter.values.get("id"));
}
}
else
{
formData.append("id", currentSavedFilter.values.get("id"));
formData.append("label", currentSavedFilter?.values.get("label"));
}
const recordList = await makeSavedFilterRequest("storeSavedFilter", formData);
await(async() =>
{
if (recordList && recordList.length > 0)
{
setSavedFiltersHaveLoaded(false);
loadSavedFilters();
handleSavedFilterRecordOnClick(recordList[0]);
}
})();
}
}
catch (e: any)
{
setPopupAlertContent(JSON.stringify(e.message));
}
}
/*******************************************************************************
** hides/shows the save options
*******************************************************************************/
const handleToggleSaveOptions = () =>
{
setSaveOptionsOpen((prevOpen) => !prevOpen);
};
/*******************************************************************************
** closes save options menu (on clickaway)
*******************************************************************************/
const handleSaveOptionsMenuClose = (event: Event) =>
{
if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement))
{
return;
}
setSaveOptionsOpen(false);
};
/*******************************************************************************
** stores the current dialog input text to state
*******************************************************************************/
const handleSaveFilterInputChange = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) =>
{
setSavedFilterNameInputValue(event.target.value);
};
/*******************************************************************************
** closes current dialog
*******************************************************************************/
const handleSaveFilterPopupClose = () =>
{
setSaveFilterPopupOpen(false);
};
/*******************************************************************************
** make a request to the backend for various savedFilter processes
*******************************************************************************/
async function makeSavedFilterRequest(processName: string, formData: FormData): Promise<QRecord[]>
{
/////////////////////////
// fetch saved filters //
/////////////////////////
let savedFilters = [] as QRecord[]
try
{
//////////////////////////////////////////////////////////////////
// we don't want this job to go async, so, pass a large timeout //
//////////////////////////////////////////////////////////////////
formData.append("_qStepTimeoutMillis", 60 * 1000);
const formDataHeaders = {
"content-type": "multipart/form-data; boundary=--------------------------320289315924586491558366",
};
const processResult = await qController.processInit(processName, formData, formDataHeaders);
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
throw(jobError.error);
}
else
{
const result = processResult as QJobComplete;
if(result.values.savedFilterList)
{
for (let i = 0; i < result.values.savedFilterList.length; i++)
{
const qRecord = new QRecord(result.values.savedFilterList[i]);
savedFilters.push(qRecord);
}
}
}
}
catch (e)
{
throw(e);
}
return (savedFilters);
}
const renderSavedFiltersMenu = tableMetaData && (
<Menu
sx={{width: "500px"}}
anchorEl={savedFiltersMenu}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
open={Boolean(savedFiltersMenu)}
onClose={closeSavedFiltersMenu}
keepMounted
>
{
savedFilters && savedFilters.length > 0 ? (
savedFilters.map((record: QRecord, index: number) =>
<MenuItem key={`savedFiler-${index}`} onClick={() => handleSavedFilterRecordOnClick(record)}>
{record.values.get("label")}
</MenuItem>
)
): (
<MenuItem >
<i>No filters have been saved for this table.</i>
</MenuItem>
)
}
</Menu>
);
const hasStorePermission = metaData?.processes.has("storeSavedFilter");
const hasDeletePermission = metaData?.processes.has("deleteSavedFilter");
const hasQueryPermission = metaData?.processes.has("querySavedFilter");
return (
hasQueryPermission && tableMetaData ? (
<Box display="flex" flexGrow={1}>
<QSavedFiltersMenuButton isOpen={savedFiltersMenu} onClickHandler={openSavedFiltersMenu} />
{renderSavedFiltersMenu}
<Box display="flex" justifyContent="flex-end" flexDirection="column">
<Box pl={2} pr={2} sx={{display: "flex", alignItems: "center"}}>
{
savedFiltersHaveLoaded && (
currentSavedFilter ? (
<Typography mr={2} variant="h6">Current Filter:&nbsp;
<span style={{fontWeight: "initial"}}>
{currentSavedFilter.values.get("label")}
{
filterIsModified && (
<i>&nbsp;*</i>
)
}
</span>
</Typography>
) : (
<Typography mr={2} variant="h6">&nbsp;
<span style={{fontWeight: "initial"}}>&nbsp;</span>
</Typography>
)
)
}
</Box>
<Box pl={2} m={0} sx={{display: "flex", alignItems: "center"}}>
<ButtonGroup variant="text" ref={anchorRef}>
{
hasStorePermission && (
<Button sx={{minHeight: 1, margin: 0, padding: 0, minWidth: "initial !important", border: "0 !important"}} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>{SAVE_OPTION}</Button>
)
}
{
currentSavedFilter && (
<Button sx={{minHeight: 1, margin: 0, padding: 0, minWidth: "20px !important", border: 0}} onClick={handleToggleSaveOptions} >
<KeyboardArrowDown />
</Button>
)
}
</ButtonGroup>
<Popper
sx={{
zIndex: 10,
marginLeft: "175px !important"
}}
open={saveOptionsOpen}
anchorEl={anchorRef.current}
transition
disablePortal
role={undefined}
nonce={undefined}
onResizeCapture={undefined}
onResize={null}>
{({TransitionProps, placement}) => (
<Grow
{...TransitionProps}
style={{
transformOrigin: "inherit",
}}
>
<Paper>
<ClickAwayListener onClickAway={handleSaveOptionsMenuClose}>
<MenuList id="split-button-menu" autoFocusItem>
{dropdownOptions.map((option, index) => (
(option === CLEAR_OPTION || ((option !== DELETE_OPTION || hasDeletePermission) && (option !== SAVE_AS_OPTION || hasStorePermission))) && (
<MenuItem
key={option}
onClick={() => handleDropdownOptionClick(option)}
>
{option}
</MenuItem>
)
))}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</Box>
</Box>
{
<Dialog
open={saveFilterPopupOpen}
onClose={handleSaveFilterPopupClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
{
currentSavedFilter ? (
isDeleteFilter ? (
<DialogTitle id="alert-dialog-title">Delete Filter</DialogTitle>
) : (
isSaveFilterAs ? (
<DialogTitle id="alert-dialog-title">Save Filter As</DialogTitle>
):(
isRenameFilter ? (
<DialogTitle id="alert-dialog-title">Rename Filter</DialogTitle>
):(
<DialogTitle id="alert-dialog-title">Update Existing Filter</DialogTitle>
)
)
)
):(
<DialogTitle id="alert-dialog-title">Save New Filter</DialogTitle>
)
}
<DialogContent sx={{width: "500px"}}>
{
(! currentSavedFilter || isSaveFilterAs || isRenameFilter) && ! isDeleteFilter ? (
<Box>
{
isSaveFilterAs ? (
<Box mb={3}>Enter a name for this new saved filter.</Box>
):(
<Box mb={3}>Enter a new name for this saved filter.</Box>
)
}
<TextField
autoFocus
name="custom-delimiter-value"
placeholder="Filter Name"
label="Filter Name"
inputProps={{width: "100%", maxLength: 100}}
sx={{width: "100%"}}
onChange={handleSaveFilterInputChange}
/>
</Box>
):(
isDeleteFilter ? (
<Box>Are you sure you want to delete the filter {`'${currentSavedFilter?.values.get("label")}'`}?</Box>
):(
<Box>Are you sure you want to update the filter {`'${currentSavedFilter?.values.get("label")}'`} with the current filter criteria?</Box>
)
)
}
{popupAlertContent ? (
<Box m={1}>
<Alert severity="error">{popupAlertContent}</Alert>
</Box>
) : ("")}
</DialogContent>
<DialogActions>
<QCancelButton onClickHandler={handleSaveFilterPopupClose} disabled={false} />
{
isDeleteFilter ?
<QDeleteButton onClickHandler={handleFilterDialogButtonOnClick} />
:
<QSaveButton label="Save" onClickHandler={handleFilterDialogButtonOnClick} disabled={(isSaveFilterAs || currentSavedFilter == null) && savedFilterNameInputValue == null}/>
}
</DialogActions>
</Dialog>
}
</Box>
) : null
);
}
export default SavedFilters;

View File

@ -20,10 +20,12 @@
*/
import {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete";
import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {Alert, TablePagination} from "@mui/material";
import Box from "@mui/material/Box";
@ -42,21 +44,23 @@ import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Modal from "@mui/material/Modal";
import Tooltip from "@mui/material/Tooltip";
import {DataGridPro, GridCallbackDetails, GridColDef, GridColumnOrderChangeParams, GridColumnVisibilityModel, GridDensity, GridEventListener, GridExportMenuItemProps, GridFilterModel, GridLinkOperator, GridPinnedColumns, gridPreferencePanelStateSelector, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, GridToolbarFilterButton, MuiEvent, useGridApiContext, useGridApiEventHandler, useGridSelector} from "@mui/x-data-grid-pro";
import {DataGridPro, GridCallbackDetails, GridColDef, GridColumnOrderChangeParams, GridColumnVisibilityModel, GridDensity, GridEventListener, GridExportMenuItemProps, GridFilterModel, GridPinnedColumns, gridPreferencePanelStateSelector, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, GridToolbarFilterButton, MuiEvent, useGridApiContext, useGridApiEventHandler, useGridSelector} from "@mui/x-data-grid-pro";
import FormData from "form-data";
import React, {useContext, useEffect, useReducer, useRef, useState} from "react";
import {useLocation, useNavigate, useSearchParams} from "react-router-dom";
import QContext from "QContext";
import {QActionsMenuButton, QCreateNewButton} from "qqq/components/buttons/DefaultButtons";
import Footer from "qqq/components/horseshoe/Footer";
import NavBar from "qqq/components/horseshoe/NavBar";
import SavedFilters from "qqq/components/misc/SavedFilters";
import DashboardLayout from "qqq/layouts/DashboardLayout";
import ProcessRun from "qqq/pages/processes/ProcessRun";
import DataGridUtils from "qqq/utils/DataGridUtils";
import Client from "qqq/utils/qqq/Client";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
const CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedFilterId";
const COLUMN_VISIBILITY_LOCAL_STORAGE_KEY_ROOT = "qqq.columnVisibility";
const COLUMN_SORT_LOCAL_STORAGE_KEY_ROOT = "qqq.columnSort";
const FILTER_LOCAL_STORAGE_KEY_ROOT = "qqq.filter";
@ -77,86 +81,6 @@ RecordQuery.defaultProps = {
const qController = Client.getInstance();
/*******************************************************************************
** Get the default filter to use on the page - either from query string, or
** local storage, or a default (empty).
*******************************************************************************/
async function getDefaultFilter(tableMetaData: QTableMetaData, searchParams: URLSearchParams, filterLocalStorageKey: string): Promise<GridFilterModel>
{
if (tableMetaData.fields !== undefined)
{
if (searchParams.has("filter"))
{
try
{
const qQueryFilter = JSON.parse(searchParams.get("filter")) as QQueryFilter;
//////////////////////////////////////////////////////////////////
// translate from a qqq-style filter to one that the grid wants //
//////////////////////////////////////////////////////////////////
const defaultFilter = {items: []} as GridFilterModel;
let id = 1;
for (let i = 0; i < qQueryFilter.criteria.length; i++)
{
const criteria = qQueryFilter.criteria[i];
const field = tableMetaData.fields.get(criteria.fieldName);
let values = criteria.values;
if (field.possibleValueSourceName)
{
//////////////////////////////////////////////////////////////////////////////////
// possible-values in query-string are expected to only be their id values. //
// e.g., ...values=[1]... //
// but we need them to be possibleValue objects (w/ id & label) so the label //
// can be shown in the filter dropdown. So, make backend call to look them up. //
//////////////////////////////////////////////////////////////////////////////////
if (values && values.length > 0)
{
values = await qController.possibleValues(tableMetaData.name, field.name, "", values);
}
////////////////////////////////////////////
// log message if no values were returned //
////////////////////////////////////////////
if (! values || values.length === 0)
{
console.warn("WARNING: No possible values were returned for [" + field.possibleValueSourceName + "] for values [" + criteria.values + "].");
}
}
defaultFilter.items.push({
columnField: criteria.fieldName,
operatorValue: FilterUtils.qqqCriteriaOperatorToGrid(criteria.operator, field, values),
value: FilterUtils.qqqCriteriaValuesToGrid(criteria.operator, values, field),
id: id++, // not sure what this id is!!
});
}
defaultFilter.linkOperator = GridLinkOperator.And;
if (qQueryFilter.booleanOperator === "OR")
{
defaultFilter.linkOperator = GridLinkOperator.Or;
}
return (defaultFilter);
}
catch (e)
{
console.warn("Error parsing filter from query string", e);
}
}
if (localStorage.getItem(filterLocalStorageKey))
{
const defaultFilter = JSON.parse(localStorage.getItem(filterLocalStorageKey));
console.log(`Got default from LS: ${JSON.stringify(defaultFilter)}`);
return (defaultFilter);
}
}
return ({items: []});
}
function RecordQuery({table, launchProcess}: Props): JSX.Element
{
const tableName = table.name;
@ -170,6 +94,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
////////////////////////////////////////////
// look for defaults in the local storage //
////////////////////////////////////////////
const currentSavedFilterLocalStorageKey = `${CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT}.${tableName}`;
const sortLocalStorageKey = `${COLUMN_SORT_LOCAL_STORAGE_KEY_ROOT}.${tableName}`;
const rowsPerPageLocalStorageKey = `${ROWS_PER_PAGE_LOCAL_STORAGE_KEY_ROOT}.${tableName}`;
const pinnedColumnsLocalStorageKey = `${PINNED_COLUMNS_LOCAL_STORAGE_KEY_ROOT}.${tableName}`;
@ -198,8 +123,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{
defaultPinnedColumns = JSON.parse(localStorage.getItem(pinnedColumnsLocalStorageKey));
}
if (localStorage.getItem(rowsPerPageLocalStorageKey))
{
defaultRowsPerPage = JSON.parse(localStorage.getItem(rowsPerPageLocalStorageKey));
@ -217,6 +140,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
const [pinnedColumns, setPinnedColumns] = useState(defaultPinnedColumns);
const [tableState, setTableState] = useState("");
const [metaData, setMetaData] = useState(null as QInstance);
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
const [defaultFilterLoaded, setDefaultFilterLoaded] = useState(false);
const [actionsMenu, setActionsMenu] = useState(null);
@ -236,6 +160,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
const [gridPreferencesWindow, setGridPreferencesWindow] = useState(undefined);
const [showClearFiltersWarning, setShowClearFiltersWarning] = useState(false);
const [hasValidFilters, setHasValidFilters] = useState(false);
const [currentSavedFilter, setCurrentSavedFilter] = useState(null as QRecord);
const [activeModalProcess, setActiveModalProcess] = useState(null as QProcessMetaData);
const [launchingProcess, setLaunchingProcess] = useState(launchProcess);
@ -285,6 +210,60 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
console.log(`Couldn't find process named ${processName}`);
}
}
/////////////////////////////////////////////////////////////////////
// the path for a savedFilter looks like: .../table/savedFilter/32 //
// so if path has '/savedFilter/' get last parsed string //
/////////////////////////////////////////////////////////////////////
let currentSavedFilterId = null as number;
if(location.pathname.indexOf("/savedFilter/") != -1)
{
const parts = location.pathname.split("/");
currentSavedFilterId = Number.parseInt(parts[parts.length - 1]);
}
else
{
if (localStorage.getItem(currentSavedFilterLocalStorageKey))
{
currentSavedFilterId = Number.parseInt(localStorage.getItem(currentSavedFilterLocalStorageKey));
navigate(`${metaData.getTablePathByName(tableName)}/savedFilter/${currentSavedFilterId}`);
}
else
{
setCurrentSavedFilter(null);
}
}
if(currentSavedFilterId != null)
{
(async () =>
{
const formData = new FormData();
formData.append("id", currentSavedFilterId);
//////////////////////////////////////////////////////////////////
// we don't want this job to go async, so, pass a large timeout //
//////////////////////////////////////////////////////////////////
formData.append("_qStepTimeoutMillis", 60 * 1000);
const formDataHeaders = {
"content-type": "multipart/form-data; boundary=--------------------------320289315924586491558366",
};
const processResult = await qController.processInit("querySavedFilter", formData, formDataHeaders);
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
console.error("Could not retrieve saved filter: " + jobError.userFacingError);
}
else
{
const result = processResult as QJobComplete;
const qRecord = new QRecord(result.values.savedFilterList[0]);
setCurrentSavedFilter(qRecord);
}
})();
}
}
catch (e)
{
@ -294,69 +273,19 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
////////////////////////////////////////////////////////////////////////////////////
// if we didn't open a process... not sure what we do in the table/query use-case //
////////////////////////////////////////////////////////////////////////////////////
setActiveModalProcess(null);
}, [ location ]);
}, [ location , tableMetaData]);
const buildQFilter = (filterModel: GridFilterModel) =>
{
console.log("Building q filter with model:");
console.log(filterModel);
const qFilter = new QQueryFilter();
if (columnSortModel)
{
columnSortModel.forEach((gridSortItem) =>
{
qFilter.addOrderBy(new QFilterOrderBy(gridSortItem.field, gridSortItem.sort === "asc"));
});
}
if (filterModel)
{
let foundFilter = false;
filterModel.items.forEach((item) =>
{
/////////////////////////////////////////////////////////////////////////
// set the values for these operators that otherwise don't have values //
/////////////////////////////////////////////////////////////////////////
if(item.operatorValue === "isTrue")
{
item.value = [true];
}
else if(item.operatorValue === "isFalse")
{
item.value = [false];
}
////////////////////////////////////////////////////////////////////////////////
// if no value set and not 'empty' or 'not empty' operators, skip this filter //
////////////////////////////////////////////////////////////////////////////////
if((! item.value || item.value.length == 0) && item.operatorValue !== "isEmpty" && item.operatorValue !== "isNotEmpty")
{
return;
}
const operator = FilterUtils.gridCriteriaOperatorToQQQ(item.operatorValue);
const values = FilterUtils.gridCriteriaValueToQQQ(operator, item.value, item.operatorValue);
qFilter.addCriteria(new QFilterCriteria(item.columnField, operator, values));
foundFilter = true;
});
setHasValidFilters(foundFilter);
qFilter.booleanOperator = "AND";
if (filterModel.linkOperator == "or")
{
///////////////////////////////////////////////////////////////////////////////////////////
// by default qFilter uses AND - so only if we see linkOperator=or do we need to set it //
///////////////////////////////////////////////////////////////////////////////////////////
qFilter.booleanOperator = "OR";
}
}
return qFilter;
const filter = FilterUtils.buildQFilterFromGridFilter(filterModel, columnSortModel);
setHasValidFilters(filter.criteria && filter.criteria.length > 0);
return(filter);
};
const getTableMetaData = async (): Promise<QTableMetaData> =>
{
if(tableMetaData !== null)
@ -388,10 +317,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
if (!defaultFilterLoaded)
{
setDefaultFilterLoaded(true);
localFilterModel = await getDefaultFilter(tableMetaData, searchParams, filterLocalStorageKey);
setFilterModel(localFilterModel);
let models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, null, searchParams, filterLocalStorageKey, sortLocalStorageKey);
setFilterModel(models.filter);
setColumnSortModel(models.sort);
return;
}
setTableMetaData(tableMetaData);
setTableLabel(tableMetaData.label);
if (columnSortModel.length === 0)
@ -588,7 +520,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
clearTimeout(instance.current.timer);
instance.current.timer = setTimeout(() =>
{
navigate(`${params.id}`);
navigate(`${metaData.getTablePathByName(tableName)}/${params.id}`);
}, 100);
}
else
@ -663,7 +595,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{
setTableState(tableName);
const metaData = await qController.loadMetaData();
ValueUtils.qInstance = metaData;
setMetaData(metaData);
setTableProcesses(ProcessUtils.getProcessesForTable(metaData, tableName)); // these are the ones to show in the dropdown
setAllTableProcesses(ProcessUtils.getProcessesForTable(metaData, tableName, true)); // these include hidden ones (e.g., to find the bulks)
@ -813,7 +745,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
setRecordIdsForProcess("");
}
navigate(`${process.name}${getRecordsQueryString()}`);
navigate(`${metaData?.getTablePathByName(tableName)}/${process.name}${getRecordsQueryString()}`);
closeActionsMenu();
};
@ -922,7 +854,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
component="div"
// note - passing null here makes the 'to' param in the defaultLabelDisplayedRows also be null,
// so pass some sentinel value...
count={totalRecords === null ? -1 : totalRecords}
count={totalRecords === null || totalRecords === undefined ? -1 : totalRecords}
page={pageNumber}
rowsPerPageOptions={[ 10, 25, 50, 100, 250 ]}
rowsPerPage={rowsPerPage}
@ -940,6 +872,54 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
);
}
async function handleSavedFilterChange(selectedSavedFilterId: number)
{
if(selectedSavedFilterId != null)
{
const qRecord = await fetchSavedFilter(selectedSavedFilterId);
const models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, qRecord.values.get("filterJson"), null, null,null);
handleFilterChange(models.filter);
handleSortChange(models.sort);
localStorage.setItem(currentSavedFilterLocalStorageKey, selectedSavedFilterId.toString());
}
else
{
handleFilterChange({items: []} as GridFilterModel);
handleSortChange([{field: tableMetaData.primaryKeyField, sort: "desc"}]);
localStorage.removeItem(currentSavedFilterLocalStorageKey);
}
}
async function fetchSavedFilter(filterId: number):Promise<QRecord>
{
let qRecord = null;
const formData = new FormData();
formData.append("id", filterId);
//////////////////////////////////////////////////////////////////
// we don't want this job to go async, so, pass a large timeout //
//////////////////////////////////////////////////////////////////
formData.append("_qStepTimeoutMillis", 60 * 1000);
const formDataHeaders = {
"content-type": "multipart/form-data; boundary=--------------------------320289315924586491558366",
};
const processResult = await qController.processInit("querySavedFilter", formData, formDataHeaders);
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
console.error("Could not retrieve saved filter: " + jobError.userFacingError);
}
else
{
const result = processResult as QJobComplete;
qRecord = new QRecord(result.values.savedFilterList[0]);
}
return(qRecord);
}
function CustomToolbar()
{
const handleMouseDown: GridEventListener<"cellMouseDown"> = (
@ -1003,6 +983,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
<Button onClick={() =>
{
setShowClearFiltersWarning(false);
navigate(metaData.getTablePathByName(tableName));
handleFilterChange({items: []} as GridFilterModel);
}}>Yes</Button>
</DialogActions>
@ -1120,7 +1101,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
////////////////////////////////////////////////////////////////////////////////////////
updateTable();
}
}, [ pageNumber, rowsPerPage, columnSortModel ]);
}, [ pageNumber, rowsPerPage, columnSortModel, currentSavedFilter ]);
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// for state changes that DO change the filter, call to update the table - and DO clear out the totalRecords //
@ -1185,15 +1166,17 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
) : null
}
<Box display="flex" justifyContent="flex-end" alignItems="flex-start" mb={2}>
<Box display="flex" marginRight="auto">
<SavedFilters qController={qController} metaData={metaData} tableMetaData={tableMetaData} currentSavedFilter={currentSavedFilter} filterModel={filterModel} columnSortModel={columnSortModel} filterOnChangeCallback={handleSavedFilterChange}/>
</Box>
<Box display="flex" width="150px">
<QActionsMenuButton isOpen={actionsMenu} onClickHandler={openActionsMenu} />
{renderActionsMenu}
</Box>
{
table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission &&
<QCreateNewButton />
<QCreateNewButton tablePath={metaData?.getTablePathByName(tableName)} />
}
</Box>
@ -1215,7 +1198,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
// getRowHeight={() => "auto"} // maybe nice? wraps values in cells...
columns={columnsModel}
rowBuffer={10}
rowCount={totalRecords === null ? 0 : totalRecords}
rowCount={totalRecords === null || totalRecords === undefined ? 0 : totalRecords}
onPageSizeChange={handleRowsPerPageChange}
onRowClick={handleRowClick}
onStateChange={handleStateChange}

View File

@ -19,9 +19,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {GridFilterModel, GridLinkOperator, GridSortItem} from "@mui/x-data-grid-pro";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
/*******************************************************************************
@ -301,7 +307,7 @@ class FilterUtils
const fieldType = field.type;
if (operator === QCriteriaOperator.IS_BLANK || operator === QCriteriaOperator.IS_NOT_BLANK)
{
return (null);
return null;
}
else if (operator === QCriteriaOperator.IN || operator === QCriteriaOperator.NOT_IN || operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN)
{
@ -321,6 +327,171 @@ class FilterUtils
return (values[0]);
};
/*******************************************************************************
** Get the default filter to use on the page - either from given filter string, query string param, or
** local storage, or a default (empty).
*******************************************************************************/
public static async determineFilterAndSortModels(qController: QController, tableMetaData: QTableMetaData, filterString: string, searchParams: URLSearchParams, filterLocalStorageKey: string, sortLocalStorageKey: string): Promise<{ filter: GridFilterModel, sort: GridSortItem[] }>
{
let defaultFilter = {items: []} as GridFilterModel;
let defaultSort = [] as GridSortItem[];
if (tableMetaData.fields !== undefined)
{
if (filterString != null || (searchParams && searchParams.has("filter")))
{
try
{
const qQueryFilter = (filterString !== null) ? JSON.parse(filterString) : JSON.parse(searchParams.get("filter")) as QQueryFilter;
//////////////////////////////////////////////////////////////////
// translate from a qqq-style filter to one that the grid wants //
//////////////////////////////////////////////////////////////////
let id = 1;
for (let i = 0; i < qQueryFilter?.criteria?.length; i++)
{
const criteria = qQueryFilter.criteria[i];
const field = tableMetaData.fields.get(criteria.fieldName);
let values = criteria.values;
if (field.possibleValueSourceName)
{
//////////////////////////////////////////////////////////////////////////////////
// possible-values in query-string are expected to only be their id values. //
// e.g., ...values=[1]... //
// but we need them to be possibleValue objects (w/ id & label) so the label //
// can be shown in the filter dropdown. So, make backend call to look them up. //
//////////////////////////////////////////////////////////////////////////////////
if (values && values.length > 0)
{
values = await qController.possibleValues(tableMetaData.name, field.name, "", values);
}
////////////////////////////////////////////
// log message if no values were returned //
////////////////////////////////////////////
if (!values || values.length === 0)
{
console.warn("WARNING: No possible values were returned for [" + field.possibleValueSourceName + "] for values [" + criteria.values + "].");
}
}
defaultFilter.items.push({
columnField: criteria.fieldName,
operatorValue: FilterUtils.qqqCriteriaOperatorToGrid(criteria.operator, field, values),
value: FilterUtils.qqqCriteriaValuesToGrid(criteria.operator, values, field),
id: id++, // not sure what this id is!!
});
}
defaultFilter.linkOperator = GridLinkOperator.And;
if (qQueryFilter.booleanOperator === "OR")
{
defaultFilter.linkOperator = GridLinkOperator.Or;
}
//////////////////////////////////////////////////////////////////
// translate from a qqq-style filter to one that the grid wants //
//////////////////////////////////////////////////////////////////
if (qQueryFilter.orderBys && qQueryFilter.orderBys.length > 0)
{
for (let i = 0; i < qQueryFilter.orderBys.length; i++)
{
const orderBy = qQueryFilter.orderBys[i];
defaultSort.push({
field: orderBy.fieldName,
sort: orderBy.isAscending ? "asc" : "desc"
});
}
}
return ({filter: defaultFilter, sort: defaultSort});
}
catch (e)
{
console.warn("Error parsing filter from query string", e);
}
}
if (localStorage.getItem(filterLocalStorageKey))
{
defaultFilter = JSON.parse(localStorage.getItem(filterLocalStorageKey));
console.log(`Got default from LS: ${JSON.stringify(defaultFilter)}`);
}
if (localStorage.getItem(sortLocalStorageKey))
{
defaultSort = JSON.parse(localStorage.getItem(sortLocalStorageKey));
console.log(`Got default from LS: ${JSON.stringify(defaultSort)}`);
}
}
return ({filter: defaultFilter, sort: defaultSort});
}
/*******************************************************************************
** build a qqq filter from a grid and column sort model
*******************************************************************************/
public static buildQFilterFromGridFilter(filterModel: GridFilterModel, columnSortModel: GridSortItem[]): QQueryFilter
{
console.log("Building q filter with model:");
console.log(filterModel);
const qFilter = new QQueryFilter();
if (columnSortModel)
{
columnSortModel.forEach((gridSortItem) =>
{
qFilter.addOrderBy(new QFilterOrderBy(gridSortItem.field, gridSortItem.sort === "asc"));
});
}
if (filterModel)
{
let foundFilter = false;
filterModel.items.forEach((item) =>
{
/////////////////////////////////////////////////////////////////////////
// set the values for these operators that otherwise don't have values //
/////////////////////////////////////////////////////////////////////////
if (item.operatorValue === "isTrue")
{
item.value = [true];
}
else if (item.operatorValue === "isFalse")
{
item.value = [false];
}
////////////////////////////////////////////////////////////////////////////////
// if no value set and not 'empty' or 'not empty' operators, skip this filter //
////////////////////////////////////////////////////////////////////////////////
if ((!item.value || item.value.length == 0) && item.operatorValue !== "isEmpty" && item.operatorValue !== "isNotEmpty")
{
return;
}
const operator = FilterUtils.gridCriteriaOperatorToQQQ(item.operatorValue);
const values = FilterUtils.gridCriteriaValueToQQQ(operator, item.value, item.operatorValue);
qFilter.addCriteria(new QFilterCriteria(item.columnField, operator, values));
foundFilter = true;
});
qFilter.booleanOperator = "AND";
if (filterModel.linkOperator == "or")
{
///////////////////////////////////////////////////////////////////////////////////////////
// by default qFilter uses AND - so only if we see linkOperator=or do we need to set it //
///////////////////////////////////////////////////////////////////////////////////////////
qFilter.booleanOperator = "OR";
}
}
return qFilter;
};
}
export default FilterUtils;