mirror of
https://github.com/Kingsrook/qqq-frontend-material-dashboard.git
synced 2025-07-17 12:50:43 +00:00
CE-798 Redesign of query screen controls - moving columns & sort & export controls out of grid; css from Paul
This commit is contained in:
@ -37,7 +37,7 @@ interface QCreateNewButtonProps
|
||||
export function QCreateNewButton({tablePath}: QCreateNewButtonProps): JSX.Element
|
||||
{
|
||||
return (
|
||||
<Box ml={3} mr={0} width={standardWidth}>
|
||||
<Box display="inline-block" ml={3} mr={0} width={standardWidth}>
|
||||
<Link to={`${tablePath}/create`}>
|
||||
<MDButton variant="gradient" color="info" fullWidth startIcon={<Icon>add</Icon>}>
|
||||
Create New
|
||||
@ -127,24 +127,6 @@ export function QActionsMenuButton({isOpen, onClickHandler}: QActionsMenuButtonP
|
||||
);
|
||||
}
|
||||
|
||||
export function QSavedViewsMenuButton({isOpen, onClickHandler}: QActionsMenuButtonProps): JSX.Element
|
||||
{
|
||||
return (
|
||||
<Box width={standardWidth} ml={1}>
|
||||
<MDButton
|
||||
variant={isOpen ? "contained" : "outlined"}
|
||||
color="dark"
|
||||
onClick={onClickHandler}
|
||||
fullWidth
|
||||
startIcon={<Icon>visibility</Icon>}
|
||||
>
|
||||
Saved Views
|
||||
<Icon>keyboard_arrow_down</Icon>
|
||||
</MDButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface QCancelButtonProps
|
||||
{
|
||||
onClickHandler: any;
|
||||
|
@ -25,10 +25,7 @@ import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QT
|
||||
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 {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
|
||||
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
|
||||
import {FiberManualRecord} from "@mui/icons-material";
|
||||
import {Alert} from "@mui/material";
|
||||
import {Alert, Button, Link} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
@ -42,15 +39,15 @@ import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import {TooltipProps} from "@mui/material/Tooltip/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import FormData from "form-data";
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import React, {useContext, useEffect, useRef, useState} from "react";
|
||||
import {useLocation, useNavigate} from "react-router-dom";
|
||||
import {QCancelButton, QDeleteButton, QSaveButton, QSavedViewsMenuButton} from "qqq/components/buttons/DefaultButtons";
|
||||
import QQueryColumns from "qqq/models/query/QQueryColumns";
|
||||
import QContext from "QContext";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import {QCancelButton, QDeleteButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
|
||||
import RecordQueryView from "qqq/models/query/RecordQueryView";
|
||||
import FilterUtils from "qqq/utils/qqq/FilterUtils";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils";
|
||||
|
||||
interface Props
|
||||
{
|
||||
@ -58,13 +55,14 @@ interface Props
|
||||
metaData: QInstance;
|
||||
tableMetaData: QTableMetaData;
|
||||
currentSavedView: QRecord;
|
||||
tableDefaultView: RecordQueryView;
|
||||
view?: RecordQueryView;
|
||||
viewAsJson?: string;
|
||||
viewOnChangeCallback?: (selectedSavedViewId: number) => void;
|
||||
loadingSavedView: boolean
|
||||
}
|
||||
|
||||
function SavedViews({qController, metaData, tableMetaData, currentSavedView, view, viewAsJson, viewOnChangeCallback, loadingSavedView}: Props): JSX.Element
|
||||
function SavedViews({qController, metaData, tableMetaData, currentSavedView, tableDefaultView, view, viewAsJson, viewOnChangeCallback, loadingSavedView}: Props): JSX.Element
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
|
||||
@ -91,6 +89,8 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie
|
||||
const CLEAR_OPTION = "New View";
|
||||
const dropdownOptions = [DUPLICATE_OPTION, RENAME_OPTION, DELETE_OPTION, CLEAR_OPTION];
|
||||
|
||||
const {accentColor, accentColorLight} = useContext(QContext);
|
||||
|
||||
const openSavedViewsMenu = (event: any) => setSavedViewsMenu(event.currentTarget);
|
||||
const closeSavedViewsMenu = () => setSavedViewsMenu(null);
|
||||
|
||||
@ -107,385 +107,14 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie
|
||||
}, [location, tableMetaData])
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
const fieldNameToLabel = (fieldName: string): string =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const [fieldMetaData, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
|
||||
if(fieldTable.name != tableMetaData.name)
|
||||
{
|
||||
return (tableMetaData.label + ": " + fieldMetaData.label);
|
||||
}
|
||||
|
||||
return (fieldMetaData.label);
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
return (fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
const diffFilters = (savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void =>
|
||||
{
|
||||
try
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// inner helper function for reporting on the number of criteria for a field. //
|
||||
// e.g., will tell us "added criteria X" or "removed 2 criteria on Y" //
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
const diffCriteriaFunction = (base: QQueryFilter, compare: QQueryFilter, messagePrefix: string, isCheckForChanged = false) =>
|
||||
{
|
||||
const baseCriteriaMap: { [name: string]: QFilterCriteria[] } = {};
|
||||
base?.criteria?.forEach((criteria) =>
|
||||
{
|
||||
if(!baseCriteriaMap[criteria.fieldName])
|
||||
{
|
||||
baseCriteriaMap[criteria.fieldName] = []
|
||||
}
|
||||
baseCriteriaMap[criteria.fieldName].push(criteria)
|
||||
});
|
||||
|
||||
const compareCriteriaMap: { [name: string]: QFilterCriteria[] } = {};
|
||||
compare?.criteria?.forEach((criteria) =>
|
||||
{
|
||||
if(!compareCriteriaMap[criteria.fieldName])
|
||||
{
|
||||
compareCriteriaMap[criteria.fieldName] = []
|
||||
}
|
||||
compareCriteriaMap[criteria.fieldName].push(criteria)
|
||||
});
|
||||
|
||||
for (let fieldName of Object.keys(compareCriteriaMap))
|
||||
{
|
||||
const noBaseCriteria = baseCriteriaMap[fieldName]?.length ?? 0;
|
||||
const noCompareCriteria = compareCriteriaMap[fieldName]?.length ?? 0;
|
||||
|
||||
if(isCheckForChanged)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// first - if we're checking for changes to specific criteria (e.g., change id=5 to id<>5, //
|
||||
// or change id=5 to id=6, or change id=5 to id<>7) //
|
||||
// our "sweet spot" is if there's a single criteria on each side of the check //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(noBaseCriteria == 1 && noCompareCriteria == 1)
|
||||
{
|
||||
const baseCriteria = baseCriteriaMap[fieldName][0]
|
||||
const compareCriteria = compareCriteriaMap[fieldName][0]
|
||||
const baseValuesJSON = JSON.stringify(baseCriteria.values ?? [])
|
||||
const compareValuesJSON = JSON.stringify(compareCriteria.values ?? [])
|
||||
if(baseCriteria.operator != compareCriteria.operator || baseValuesJSON != compareValuesJSON)
|
||||
{
|
||||
viewDiffs.push(`Changed a filter from ${FilterUtils.criteriaToHumanString(tableMetaData, baseCriteria)} to ${FilterUtils.criteriaToHumanString(tableMetaData, compareCriteria)}`)
|
||||
}
|
||||
}
|
||||
else if(noBaseCriteria == noCompareCriteria)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// else - if the number of criteria on this field differs, that'll get caught in a non-isCheckForChanged call, so //
|
||||
// todo, i guess - this is kinda weak - but if there's the same number of criteria on a field, then just ... do a shitty JSON compare between them... //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const baseJSON = JSON.stringify(baseCriteriaMap[fieldName])
|
||||
const compareJSON = JSON.stringify(compareCriteriaMap[fieldName])
|
||||
if(baseJSON != compareJSON)
|
||||
{
|
||||
viewDiffs.push(`${messagePrefix} 1 or more filters on ${fieldNameToLabel(fieldName)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// else - we're not checking for changes to individual criteria - rather - we're just checking if criteria were added or removed. //
|
||||
// we'll do that by starting to see if the nubmer of criteria is different. //
|
||||
// and, only do it in only 1 direction, assuming we'll get called twice, with the base & compare sides flipped //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(noBaseCriteria < noCompareCriteria)
|
||||
{
|
||||
if (noBaseCriteria == 0 && noCompareCriteria == 1)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if the difference is 0 to 1 (1 to 0 when called in reverse), then we can report the full criteria that was added/removed //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
viewDiffs.push(`${messagePrefix} filter: ${FilterUtils.criteriaToHumanString(tableMetaData, compareCriteriaMap[fieldName][0])}`)
|
||||
}
|
||||
else
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// else, say 0 to 2, or 2 to 1 - just report on how many were changed... //
|
||||
// todo this isn't great, as you might have had, say, (A,B), and now you have (C) - but all we'll say is "removed 1"... //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const noDiffs = noCompareCriteria - noBaseCriteria;
|
||||
viewDiffs.push(`${messagePrefix} ${noDiffs} filters on ${fieldNameToLabel(fieldName)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
diffCriteriaFunction(savedView.queryFilter, activeView.queryFilter, "Added");
|
||||
diffCriteriaFunction(activeView.queryFilter, savedView.queryFilter, "Removed");
|
||||
diffCriteriaFunction(savedView.queryFilter, activeView.queryFilter, "Changed", true);
|
||||
|
||||
//////////////////////
|
||||
// boolean operator //
|
||||
//////////////////////
|
||||
if (savedView.queryFilter.booleanOperator != activeView.queryFilter.booleanOperator)
|
||||
{
|
||||
viewDiffs.push("Changed filter from 'And' to 'Or'")
|
||||
}
|
||||
|
||||
///////////////
|
||||
// order-bys //
|
||||
///////////////
|
||||
const savedOrderBys = savedView.queryFilter.orderBys;
|
||||
const activeOrderBys = activeView.queryFilter.orderBys;
|
||||
if (savedOrderBys.length != activeOrderBys.length)
|
||||
{
|
||||
viewDiffs.push("Changed sort")
|
||||
}
|
||||
else if (savedOrderBys.length > 0)
|
||||
{
|
||||
const toWord = ((b: boolean) => b ? "ascending" : "descending");
|
||||
if (savedOrderBys[0].fieldName != activeOrderBys[0].fieldName && savedOrderBys[0].isAscending != activeOrderBys[0].isAscending)
|
||||
{
|
||||
viewDiffs.push(`Changed sort from ${fieldNameToLabel(savedOrderBys[0].fieldName)} ${toWord(savedOrderBys[0].isAscending)} to ${fieldNameToLabel(activeOrderBys[0].fieldName)} ${toWord(activeOrderBys[0].isAscending)}`)
|
||||
}
|
||||
else if (savedOrderBys[0].fieldName != activeOrderBys[0].fieldName)
|
||||
{
|
||||
viewDiffs.push(`Changed sort field from ${fieldNameToLabel(savedOrderBys[0].fieldName)} to ${fieldNameToLabel(activeOrderBys[0].fieldName)}`)
|
||||
}
|
||||
else if (savedOrderBys[0].isAscending != activeOrderBys[0].isAscending)
|
||||
{
|
||||
viewDiffs.push(`Changed sort direction from ${toWord(savedOrderBys[0].isAscending)} to ${toWord(activeOrderBys[0].isAscending)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
console.log(`Error looking for differences in filters ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
const diffColumns = (savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if(!savedView.queryColumns || !savedView.queryColumns.columns || savedView.queryColumns.columns.length == 0)
|
||||
{
|
||||
viewDiffs.push("This view did not previously have columns saved with it, so the next time you save it they will be initialized.");
|
||||
return;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// nested function to help diff visible status of columns //
|
||||
////////////////////////////////////////////////////////////
|
||||
const diffVisibilityFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) =>
|
||||
{
|
||||
const baseColumnsMap: { [name: string]: boolean } = {};
|
||||
base.columns.forEach((column) =>
|
||||
{
|
||||
if (column.isVisible)
|
||||
{
|
||||
baseColumnsMap[column.name] = true;
|
||||
}
|
||||
});
|
||||
|
||||
const diffFields: string[] = [];
|
||||
for (let i = 0; i < compare.columns.length; i++)
|
||||
{
|
||||
const column = compare.columns[i];
|
||||
if(column.isVisible)
|
||||
{
|
||||
if (!baseColumnsMap[column.name])
|
||||
{
|
||||
diffFields.push(fieldNameToLabel(column.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (diffFields.length > 0)
|
||||
{
|
||||
if (diffFields.length > 5)
|
||||
{
|
||||
viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`);
|
||||
}
|
||||
else
|
||||
{
|
||||
viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// nested function to help diff pinned status of columns //
|
||||
///////////////////////////////////////////////////////////
|
||||
const diffPinsFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) =>
|
||||
{
|
||||
const baseColumnsMap: { [name: string]: string } = {};
|
||||
base.columns.forEach((column) => baseColumnsMap[column.name] = column.pinned);
|
||||
|
||||
const diffFields: string[] = [];
|
||||
for (let i = 0; i < compare.columns.length; i++)
|
||||
{
|
||||
const column = compare.columns[i];
|
||||
if (baseColumnsMap[column.name] != column.pinned)
|
||||
{
|
||||
diffFields.push(fieldNameToLabel(column.name));
|
||||
}
|
||||
}
|
||||
|
||||
if (diffFields.length > 0)
|
||||
{
|
||||
if (diffFields.length > 5)
|
||||
{
|
||||
viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`);
|
||||
}
|
||||
else
|
||||
{
|
||||
viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// nested function to help diff width of columns //
|
||||
///////////////////////////////////////////////////
|
||||
const diffWidthsFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) =>
|
||||
{
|
||||
const baseColumnsMap: { [name: string]: number } = {};
|
||||
base.columns.forEach((column) => baseColumnsMap[column.name] = column.width);
|
||||
|
||||
const diffFields: string[] = [];
|
||||
for (let i = 0; i < compare.columns.length; i++)
|
||||
{
|
||||
const column = compare.columns[i];
|
||||
if (baseColumnsMap[column.name] != column.width)
|
||||
{
|
||||
diffFields.push(fieldNameToLabel(column.name));
|
||||
}
|
||||
}
|
||||
|
||||
if (diffFields.length > 0)
|
||||
{
|
||||
if (diffFields.length > 5)
|
||||
{
|
||||
viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`);
|
||||
}
|
||||
else
|
||||
{
|
||||
viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
diffVisibilityFunction(savedView.queryColumns, activeView.queryColumns, "Turned on ");
|
||||
diffVisibilityFunction(activeView.queryColumns, savedView.queryColumns, "Turned off ");
|
||||
diffPinsFunction(savedView.queryColumns, activeView.queryColumns, "Changed pinned state for ");
|
||||
|
||||
if(savedView.queryColumns.columns.map(c => c.name).join(",") != activeView.queryColumns.columns.map(c => c.name).join(","))
|
||||
{
|
||||
viewDiffs.push("Changed the order columns.");
|
||||
}
|
||||
|
||||
diffWidthsFunction(savedView.queryColumns, activeView.queryColumns, "Changed width for ");
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.log(`Error looking for differences in columns: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
const diffQuickFilterFieldNames = (savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const diffFunction = (base: string[], compare: string[], messagePrefix: string) =>
|
||||
{
|
||||
const baseFieldNameMap: { [name: string]: boolean } = {};
|
||||
base.forEach((name) => baseFieldNameMap[name] = true);
|
||||
const diffFields: string[] = [];
|
||||
for (let i = 0; i < compare.length; i++)
|
||||
{
|
||||
const name = compare[i];
|
||||
if (!baseFieldNameMap[name])
|
||||
{
|
||||
diffFields.push(fieldNameToLabel(name));
|
||||
}
|
||||
}
|
||||
|
||||
if (diffFields.length > 0)
|
||||
{
|
||||
viewDiffs.push(`${messagePrefix} basic filter${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
diffFunction(savedView.quickFilterFieldNames, activeView.quickFilterFieldNames, "Turned on");
|
||||
diffFunction(activeView.quickFilterFieldNames, savedView.quickFilterFieldNames, "Turned off");
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.log(`Error looking for differences in quick filter field names: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const baseView = currentSavedView ? JSON.parse(currentSavedView.values.get("viewJson")) as RecordQueryView : tableDefaultView;
|
||||
const viewDiffs = SavedViewUtils.diffViews(tableMetaData, baseView, view);
|
||||
let viewIsModified = false;
|
||||
let viewDiffs:string[] = [];
|
||||
|
||||
if(currentSavedView != null)
|
||||
if(viewDiffs.length > 0)
|
||||
{
|
||||
const savedView = JSON.parse(currentSavedView.values.get("viewJson")) as RecordQueryView;
|
||||
const activeView = view;
|
||||
|
||||
diffFilters(savedView, activeView, viewDiffs);
|
||||
diffColumns(savedView, activeView, viewDiffs);
|
||||
diffQuickFilterFieldNames(savedView, activeView, viewDiffs);
|
||||
|
||||
if(savedView.mode != activeView.mode)
|
||||
{
|
||||
if(savedView.mode)
|
||||
{
|
||||
viewDiffs.push(`Mode changed from ${savedView.mode} to ${activeView.mode}`)
|
||||
}
|
||||
else
|
||||
{
|
||||
viewDiffs.push(`Mode set to ${activeView.mode}`)
|
||||
}
|
||||
}
|
||||
|
||||
if(savedView.rowsPerPage != activeView.rowsPerPage)
|
||||
{
|
||||
if(savedView.rowsPerPage)
|
||||
{
|
||||
viewDiffs.push(`Rows per page changed from ${savedView.rowsPerPage} to ${activeView.rowsPerPage}`)
|
||||
}
|
||||
else
|
||||
{
|
||||
viewDiffs.push(`Rows per page set to ${activeView.rowsPerPage}`)
|
||||
}
|
||||
}
|
||||
|
||||
if(viewDiffs.length > 0)
|
||||
{
|
||||
viewIsModified = true;
|
||||
}
|
||||
viewIsModified = true;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** make request to load all saved filters from backend
|
||||
*******************************************************************************/
|
||||
@ -534,8 +163,13 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie
|
||||
switch(optionName)
|
||||
{
|
||||
case SAVE_OPTION:
|
||||
if(currentSavedView == null)
|
||||
{
|
||||
setSavedViewNameInputValue("");
|
||||
}
|
||||
break;
|
||||
case DUPLICATE_OPTION:
|
||||
setSavedViewNameInputValue("");
|
||||
setIsSaveFilterAs(true);
|
||||
break;
|
||||
case CLEAR_OPTION:
|
||||
@ -760,13 +394,13 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie
|
||||
keepMounted
|
||||
PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minHeight: "200px"}}}
|
||||
>
|
||||
<MenuItem sx={{width: "300px"}} disabled style={{"opacity": "initial"}}><b>Actions</b></MenuItem>
|
||||
<MenuItem sx={{width: "300px"}} disabled style={{"opacity": "initial"}}><b>View Actions</b></MenuItem>
|
||||
{
|
||||
hasStorePermission &&
|
||||
<Tooltip {...menuTooltipAttribs} title={<>Save your current filters, columns and settings, for quick re-use at a later time.<br /><br />You will be prompted to enter a name if you choose this option.</>}>
|
||||
<MenuItem onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>
|
||||
<ListItemIcon><Icon>save</Icon></ListItemIcon>
|
||||
Save...
|
||||
{currentSavedView ? "Save..." : "Save As..."}
|
||||
</MenuItem>
|
||||
</Tooltip>
|
||||
}
|
||||
@ -815,48 +449,150 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie
|
||||
</MenuItem>
|
||||
)
|
||||
): (
|
||||
<MenuItem >
|
||||
<i>No views have been saved for this table.</i>
|
||||
<MenuItem>
|
||||
<i>You do not have any saved views for this table.</i>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
let buttonText = "Views";
|
||||
let buttonBackground = "none";
|
||||
let buttonBorder = colors.grayLines.main;
|
||||
let buttonColor = colors.gray.main;
|
||||
|
||||
if(loadingSavedView)
|
||||
{
|
||||
buttonText = "Loading...";
|
||||
}
|
||||
else if(currentSavedView)
|
||||
{
|
||||
buttonText = currentSavedView.values.get("label")
|
||||
}
|
||||
|
||||
if(currentSavedView)
|
||||
{
|
||||
if (viewIsModified)
|
||||
{
|
||||
buttonBackground = accentColorLight;
|
||||
buttonBorder = buttonBackground;
|
||||
buttonColor = accentColor;
|
||||
}
|
||||
else
|
||||
{
|
||||
buttonBackground = accentColor;
|
||||
buttonBorder = buttonBackground;
|
||||
buttonColor = "#FFFFFF";
|
||||
}
|
||||
}
|
||||
|
||||
const buttonStyles = {
|
||||
border: `1px solid ${buttonBorder}`,
|
||||
backgroundColor: buttonBackground,
|
||||
color: buttonColor,
|
||||
"&:focus:not(:hover)": {
|
||||
color: buttonColor,
|
||||
backgroundColor: buttonBackground,
|
||||
},
|
||||
"&:hover": {
|
||||
color: buttonColor,
|
||||
backgroundColor: buttonBackground,
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function isSaveButtonDisabled(): boolean
|
||||
{
|
||||
if(isSubmitting)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
const haveInputText = (savedViewNameInputValue != null && savedViewNameInputValue.trim() != "")
|
||||
|
||||
if(isSaveFilterAs || isRenameFilter || currentSavedView == null)
|
||||
{
|
||||
if(!haveInputText)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
|
||||
return (false);
|
||||
}
|
||||
|
||||
const linkButtonStyle = {
|
||||
minWidth: "unset",
|
||||
textTransform: "none",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: "500",
|
||||
padding: "0.5rem"
|
||||
};
|
||||
|
||||
return (
|
||||
hasQueryPermission && tableMetaData ? (
|
||||
<Box display="flex" flexGrow={1}>
|
||||
<QSavedViewsMenuButton isOpen={savedViewsMenu} onClickHandler={openSavedViewsMenu} />
|
||||
{renderSavedViewsMenu}
|
||||
<Box display="flex" justifyContent="center" flexDirection="column">
|
||||
<>
|
||||
<Box order="1" mr={"0.5rem"}>
|
||||
<Button
|
||||
onClick={openSavedViewsMenu}
|
||||
sx={{
|
||||
borderRadius: "0.75rem",
|
||||
textTransform: "none",
|
||||
fontWeight: 500,
|
||||
fontSize: "0.875rem",
|
||||
p: "0.5rem",
|
||||
... buttonStyles
|
||||
}}
|
||||
>
|
||||
<Icon sx={{mr: "0.5rem"}}>save</Icon>
|
||||
{buttonText}
|
||||
<Icon sx={{ml: "0.5rem"}}>keyboard_arrow_down</Icon>
|
||||
</Button>
|
||||
{renderSavedViewsMenu}
|
||||
</Box>
|
||||
<Box order="3" display="flex" justifyContent="center" flexDirection="column">
|
||||
<Box pl={2} pr={2} sx={{display: "flex", alignItems: "center"}}>
|
||||
{
|
||||
savedViewsHaveLoaded && currentSavedView && (
|
||||
<Typography mr={2} variant="h6">Current View:
|
||||
<span style={{fontWeight: "initial"}}>
|
||||
!currentSavedView && viewIsModified && <>
|
||||
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
|
||||
<b>Unsaved Changes</b>
|
||||
<ul style={{padding: "0.5rem 1rem"}}>
|
||||
{
|
||||
loadingSavedView
|
||||
? "..."
|
||||
:
|
||||
<>
|
||||
{currentSavedView.values.get("label")}
|
||||
{
|
||||
viewIsModified && (
|
||||
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>The current view has been modified:
|
||||
<ul style={{padding: "1rem"}}>
|
||||
{
|
||||
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
|
||||
}
|
||||
</ul>Click "Save..." to save the changes.</>}>
|
||||
<FiberManualRecord sx={{color: "orange", paddingLeft: "2px", paddingTop: "4px"}} />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</>
|
||||
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
|
||||
}
|
||||
</span>
|
||||
</Typography>
|
||||
)
|
||||
</ul>
|
||||
</>}>
|
||||
<Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save View As…</Button>
|
||||
</Tooltip>
|
||||
|
||||
{/* vertical rule */}
|
||||
<Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
|
||||
|
||||
<Button disableRipple={true} sx={{color: colors.gray.main, ... linkButtonStyle}} onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>Reset All Changes</Button>
|
||||
</>
|
||||
}
|
||||
{
|
||||
currentSavedView && viewIsModified && <>
|
||||
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
|
||||
<b>Unsaved Changes</b>
|
||||
<ul style={{padding: "0.5rem 1rem"}}>
|
||||
{
|
||||
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
|
||||
}
|
||||
</ul></>}>
|
||||
<Box display="inline" sx={{...linkButtonStyle, p: 0, cursor: "default", position: "relative", top: "-1px"}}>{viewDiffs.length} Unsaved Change{viewDiffs.length == 1 ? "" : "s"}</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save…</Button>
|
||||
|
||||
{/* vertical rule */}
|
||||
<Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
|
||||
|
||||
<Button disableRipple={true} sx={{color: colors.gray.main, ... linkButtonStyle}} onClick={() => handleSavedViewRecordOnClick(currentSavedView)}>Reset All Changes</Button>
|
||||
</>
|
||||
}
|
||||
</Box>
|
||||
</Box>
|
||||
@ -917,7 +653,6 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie
|
||||
autoFocus
|
||||
name="custom-delimiter-value"
|
||||
placeholder="View Name"
|
||||
label="View Name"
|
||||
inputProps={{width: "100%", maxLength: 100}}
|
||||
value={savedViewNameInputValue}
|
||||
sx={{width: "100%"}}
|
||||
@ -943,12 +678,12 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie
|
||||
isDeleteFilter ?
|
||||
<QDeleteButton onClickHandler={handleFilterDialogButtonOnClick} disabled={isSubmitting} />
|
||||
:
|
||||
<QSaveButton label="Save" onClickHandler={handleFilterDialogButtonOnClick} disabled={isSubmitting || ((isSaveFilterAs || currentSavedView == null) && savedViewNameInputValue == null)}/>
|
||||
<QSaveButton label="Save" onClickHandler={handleFilterDialogButtonOnClick} disabled={isSaveButtonDisabled()}/>
|
||||
}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
}
|
||||
</Box>
|
||||
</>
|
||||
) : null
|
||||
);
|
||||
}
|
||||
|
@ -27,8 +27,9 @@ import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QT
|
||||
import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection";
|
||||
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 {Badge, ToggleButton, ToggleButtonGroup, Typography} from "@mui/material";
|
||||
import {Badge, ToggleButton, ToggleButtonGroup} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
@ -37,15 +38,17 @@ import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogContentText from "@mui/material/DialogContentText";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro";
|
||||
import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react";
|
||||
import React, {forwardRef, useContext, useImperativeHandle, useReducer, useState} from "react";
|
||||
import QContext from "QContext";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
|
||||
import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete";
|
||||
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
|
||||
import FieldListMenu from "qqq/components/query/FieldListMenu";
|
||||
import {validateCriteria} from "qqq/components/query/FilterCriteriaRow";
|
||||
import QuickFilter, {quickFilterButtonStyles} from "qqq/components/query/QuickFilter";
|
||||
import XIcon from "qqq/components/query/XIcon";
|
||||
import FilterUtils from "qqq/utils/qqq/FilterUtils";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
|
||||
@ -54,6 +57,9 @@ interface BasicAndAdvancedQueryControlsProps
|
||||
metaData: QInstance;
|
||||
tableMetaData: QTableMetaData;
|
||||
|
||||
savedViewsComponent: JSX.Element;
|
||||
columnMenuComponent: JSX.Element;
|
||||
|
||||
quickFilterFieldNames: string[];
|
||||
setQuickFilterFieldNames: (names: string[]) => void;
|
||||
|
||||
@ -83,7 +89,7 @@ let debounceTimeout: string | number | NodeJS.Timeout;
|
||||
*******************************************************************************/
|
||||
const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryControlsProps, ref) =>
|
||||
{
|
||||
const {metaData, tableMetaData, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode} = props
|
||||
const {metaData, tableMetaData, savedViewsComponent, columnMenuComponent, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode} = props
|
||||
|
||||
/////////////////////
|
||||
// state variables //
|
||||
@ -95,6 +101,8 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
const [showClearFiltersWarning, setShowClearFiltersWarning] = useState(false);
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
const {accentColor} = useContext(QContext);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// make some functions available to our parent - so it can tell us to do things //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
@ -279,6 +287,11 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
const fieldName = newValue ? newValue.fieldName : null;
|
||||
if (fieldName)
|
||||
{
|
||||
if(defaultQuickFilterFieldNameMap[fieldName])
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (quickFilterFieldNames.indexOf(fieldName) == -1)
|
||||
{
|
||||
/////////////////////////////////
|
||||
@ -309,7 +322,22 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** event handler for the Filter Buidler button - e.g., opens the parent's grid's
|
||||
**
|
||||
*******************************************************************************/
|
||||
const handleFieldListMenuSelection = (field: QFieldMetaData, table: QTableMetaData): void =>
|
||||
{
|
||||
let fullFieldName = field.name;
|
||||
if(table && table.name != tableMetaData.name)
|
||||
{
|
||||
fullFieldName = `${table.name}.${field.name}`;
|
||||
}
|
||||
|
||||
addQuickFilterField({fieldName: fullFieldName}, "selectedFromAddFilterMenu");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** event handler for the Filter Builder button - e.g., opens the parent's grid's
|
||||
** filter panel
|
||||
*******************************************************************************/
|
||||
const openFilterBuilder = (e: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>) =>
|
||||
@ -326,11 +354,21 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
if (isYesButton || event.key == "Enter")
|
||||
{
|
||||
setShowClearFiltersWarning(false);
|
||||
setQueryFilter(new QQueryFilter());
|
||||
setQueryFilter(new QQueryFilter([], queryFilter.orderBys));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
const removeCriteriaByIndex = (index: number) =>
|
||||
{
|
||||
queryFilter.criteria.splice(index, 1);
|
||||
setQueryFilter(queryFilter);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** format the current query as a string for showing on-screen as a preview.
|
||||
*******************************************************************************/
|
||||
@ -344,20 +382,20 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
let counter = 0;
|
||||
|
||||
return (
|
||||
<span>
|
||||
<Box display="flex" flexWrap="wrap" fontSize="0.875rem">
|
||||
{queryFilter.criteria.map((criteria, i) =>
|
||||
{
|
||||
const {criteriaIsValid} = validateCriteria(criteria, null);
|
||||
if(criteriaIsValid)
|
||||
{
|
||||
const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, criteria.fieldName);
|
||||
counter++;
|
||||
|
||||
return (
|
||||
<span key={i}>
|
||||
{counter > 1 ? <span>{queryFilter.booleanOperator} </span> : <span/>}
|
||||
<React.Fragment key={i}>
|
||||
{counter > 1 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{queryFilter.booleanOperator} </span> : <span/>}
|
||||
{FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)}
|
||||
</span>
|
||||
<XIcon position="forAdvancedQueryPreview" onClick={() => removeCriteriaByIndex(i)} />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
else
|
||||
@ -365,7 +403,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
return (<span />);
|
||||
}
|
||||
})}
|
||||
</span>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@ -427,12 +465,15 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < queryFilter?.criteria?.length; i++)
|
||||
if(mode == "basic")
|
||||
{
|
||||
const criteria = queryFilter.criteria[i];
|
||||
if (criteria && criteria.fieldName)
|
||||
for (let i = 0; i < queryFilter?.criteria?.length; i++)
|
||||
{
|
||||
addQuickFilterField(criteria, reason);
|
||||
const criteria = queryFilter.criteria[i];
|
||||
if (criteria && criteria.fieldName)
|
||||
{
|
||||
addQuickFilterField(criteria, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -455,6 +496,70 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Event handler for setting the sort from that menu
|
||||
*******************************************************************************/
|
||||
const handleSetSort = (field: QFieldMetaData, table: QTableMetaData, isAscending: boolean = true): void =>
|
||||
{
|
||||
const fullFieldName = table && table.name != tableMetaData.name ? `${table.name}.${field.name}` : field.name;
|
||||
queryFilter.orderBys = [new QFilterOrderBy(fullFieldName, isAscending)]
|
||||
|
||||
setQueryFilter(queryFilter);
|
||||
forceUpdate();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** event handler for a click on a field's up or down arrow in the sort menu
|
||||
*******************************************************************************/
|
||||
const handleSetSortArrowClick = (field: QFieldMetaData, table: QTableMetaData, event: any): void =>
|
||||
{
|
||||
event.stopPropagation();
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// make sure this is an event handler for one of our icons (not something else in the dom here in our end-adornments) //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const isAscending = event.target.innerHTML == "arrow_upward";
|
||||
const isDescending = event.target.innerHTML == "arrow_downward";
|
||||
if(isAscending || isDescending)
|
||||
{
|
||||
handleSetSort(field, table, isAscending);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** event handler for clicking the current sort up/down arrow, to toggle direction.
|
||||
*******************************************************************************/
|
||||
function toggleSortDirection(event: React.MouseEvent<HTMLSpanElement, MouseEvent>): void
|
||||
{
|
||||
event.stopPropagation();
|
||||
try
|
||||
{
|
||||
queryFilter.orderBys[0].isAscending = !queryFilter.orderBys[0].isAscending;
|
||||
setQueryFilter(queryFilter);
|
||||
forceUpdate();
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
console.log(`Error toggling sort: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////
|
||||
// set up the sort menu button //
|
||||
/////////////////////////////////
|
||||
let sortButtonContents = <>Sort...</>
|
||||
if(queryFilter && queryFilter.orderBys && queryFilter.orderBys.length > 0)
|
||||
{
|
||||
const orderBy = queryFilter.orderBys[0];
|
||||
const orderByFieldName = orderBy.fieldName;
|
||||
const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, orderByFieldName);
|
||||
const fieldLabel = fieldTable.name == tableMetaData.name ? field.label : `${fieldTable.label}: ${field.label}`;
|
||||
sortButtonContents = <>Sort: {fieldLabel} <Icon onClick={toggleSortDirection} sx={{ml: "0.5rem"}}>{orderBy.isAscending ? "arrow_upward" : "arrow_downward"}</Icon></>
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// this is being used as a version of like forcing that we get re-rendered if the query filter changes... //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@ -481,140 +586,172 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
</>
|
||||
}
|
||||
|
||||
const borderGray = colors.grayLines.main;
|
||||
|
||||
const sortMenuComponent = (
|
||||
<FieldListMenu
|
||||
idPrefix="sort"
|
||||
tableMetaData={tableMetaData}
|
||||
placeholder="Search Fields"
|
||||
buttonProps={{disableRipple: true, sx: {textTransform: "none", color: colors.gray.main, paddingRight: 0}}}
|
||||
buttonChildren={sortButtonContents}
|
||||
isModeSelectOne={true}
|
||||
handleSelectedField={handleSetSort}
|
||||
fieldEndAdornment={<Box whiteSpace="nowrap"><Icon>arrow_upward</Icon><Icon>arrow_downward</Icon></Box>}
|
||||
handleAdornmentClick={handleSetSortArrowClick}
|
||||
/>);
|
||||
|
||||
return (
|
||||
<Box display="flex" alignItems="flex-start" justifyContent="space-between" flexWrap="wrap" position="relative" top={"-0.5rem"} left={"0.5rem"} minHeight="2.5rem">
|
||||
<Box display="flex" alignItems="center" flexShrink={1} flexGrow={1}>
|
||||
<Box pb={mode == "advanced" ? "0.25rem" : "0"}>
|
||||
|
||||
{/* First row: Saved Views button (with Columns button in the middle of it), then space-between, then basic|advanced toggle */}
|
||||
<Box display="flex" justifyContent="space-between" pt={"0.5rem"} pb={"0.5rem"}>
|
||||
<Box display="flex">
|
||||
{savedViewsComponent}
|
||||
{columnMenuComponent}
|
||||
</Box>
|
||||
<Box>
|
||||
<Tooltip title={reasonWhyBasicIsDisabled}>
|
||||
<ToggleButtonGroup
|
||||
value={mode}
|
||||
exclusive
|
||||
onChange={(event, newValue) => modeToggleClicked(newValue)}
|
||||
size="small"
|
||||
sx={{pl: 0.5, width: "10rem"}}
|
||||
>
|
||||
<ToggleButton value="basic" disabled={!canFilterWorkAsBasic}>Basic</ToggleButton>
|
||||
<ToggleButton value="advanced">Advanced</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Second row: Basic or advanced mode - with sort-by control on the right (of each) */}
|
||||
<Box pb={"0.25rem"}>
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
// basic mode - wrapping-list of fields & add-field button, then sort-by control //
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
mode == "basic" &&
|
||||
<Box width="100px" flexShrink={1} flexGrow={1}>
|
||||
<>
|
||||
{
|
||||
tableMetaData && defaultQuickFilterFieldNames?.map((fieldName) =>
|
||||
<Box display="flex" alignItems="flex-start" flexShrink={1} flexGrow={1}>
|
||||
<Box width="100px" flexShrink={1} flexGrow={1}>
|
||||
<>
|
||||
{
|
||||
const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
|
||||
let defaultOperator = getDefaultOperatorForField(field);
|
||||
tableMetaData && defaultQuickFilterFieldNames?.map((fieldName) =>
|
||||
{
|
||||
const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
|
||||
let defaultOperator = getDefaultOperatorForField(field);
|
||||
|
||||
return (<QuickFilter
|
||||
key={fieldName}
|
||||
fullFieldName={fieldName}
|
||||
tableMetaData={tableMetaData}
|
||||
updateCriteria={updateQuickCriteria}
|
||||
criteriaParam={getQuickCriteriaParam(fieldName)}
|
||||
fieldMetaData={field}
|
||||
defaultOperator={defaultOperator}
|
||||
handleRemoveQuickFilterField={null} />);
|
||||
})
|
||||
}
|
||||
<Box display="inline-block" borderLeft="1px solid gray" height="1.75rem" width="1px" marginRight="0.5rem" position="relative" top="0.5rem" />
|
||||
{
|
||||
tableMetaData && quickFilterFieldNames?.map((fieldName) =>
|
||||
return (<QuickFilter
|
||||
key={fieldName}
|
||||
fullFieldName={fieldName}
|
||||
tableMetaData={tableMetaData}
|
||||
updateCriteria={updateQuickCriteria}
|
||||
criteriaParam={getQuickCriteriaParam(fieldName)}
|
||||
fieldMetaData={field}
|
||||
defaultOperator={defaultOperator}
|
||||
handleRemoveQuickFilterField={null} />);
|
||||
})
|
||||
}
|
||||
{/* vertical rule */}
|
||||
<Box display="inline-block" borderLeft={`1px solid ${borderGray}`} height="1.75rem" width="1px" marginRight="0.5rem" position="relative" top="0.5rem" />
|
||||
{
|
||||
const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
|
||||
let defaultOperator = getDefaultOperatorForField(field);
|
||||
tableMetaData && quickFilterFieldNames?.map((fieldName) =>
|
||||
{
|
||||
const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
|
||||
let defaultOperator = getDefaultOperatorForField(field);
|
||||
|
||||
return (defaultQuickFilterFieldNameMap[fieldName] ? null : <QuickFilter
|
||||
key={fieldName}
|
||||
fullFieldName={fieldName}
|
||||
return (defaultQuickFilterFieldNameMap[fieldName] ? null : <QuickFilter
|
||||
key={fieldName}
|
||||
fullFieldName={fieldName}
|
||||
tableMetaData={tableMetaData}
|
||||
updateCriteria={updateQuickCriteria}
|
||||
criteriaParam={getQuickCriteriaParam(fieldName)}
|
||||
fieldMetaData={field}
|
||||
defaultOperator={defaultOperator}
|
||||
handleRemoveQuickFilterField={handleRemoveQuickFilterField} />);
|
||||
})
|
||||
}
|
||||
{
|
||||
tableMetaData && <FieldListMenu
|
||||
key={JSON.stringify(quickFilterFieldNames)} // use a unique key each time we open it, because we don't want the user's last selection to stick.
|
||||
idPrefix="addQuickFilter"
|
||||
tableMetaData={tableMetaData}
|
||||
updateCriteria={updateQuickCriteria}
|
||||
criteriaParam={getQuickCriteriaParam(fieldName)}
|
||||
fieldMetaData={field}
|
||||
defaultOperator={defaultOperator}
|
||||
handleRemoveQuickFilterField={handleRemoveQuickFilterField} />);
|
||||
})
|
||||
}
|
||||
{
|
||||
tableMetaData &&
|
||||
<>
|
||||
<Tooltip enterDelay={500} title="Add a Quick Filter field" placement="top">
|
||||
<Button onClick={(e) => openAddQuickFilterMenu(e)} startIcon={<Icon>add</Icon>} sx={{...quickFilterButtonStyles}}>
|
||||
Add Filter
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
anchorEl={addQuickFilterMenu}
|
||||
anchorOrigin={{vertical: "bottom", horizontal: "left"}}
|
||||
transformOrigin={{vertical: "top", horizontal: "left"}}
|
||||
transitionDuration={0}
|
||||
open={Boolean(addQuickFilterMenu)}
|
||||
onClose={closeAddQuickFilterMenu}
|
||||
keepMounted
|
||||
>
|
||||
<Box width="250px">
|
||||
<FieldAutoComplete
|
||||
key={addQuickFilterOpenCounter} // use a unique key each time we open it, because we don't want the user's last selection to stick.
|
||||
id={"add-quick-filter-field"}
|
||||
metaData={metaData}
|
||||
tableMetaData={tableMetaData}
|
||||
defaultValue={null}
|
||||
handleFieldChange={(e, newValue, reason) => addQuickFilterField(newValue, reason)}
|
||||
autoFocus={true}
|
||||
forceOpen={Boolean(addQuickFilterMenu)}
|
||||
hiddenFieldNames={[...(defaultQuickFilterFieldNames??[]), ...(quickFilterFieldNames??[])]}
|
||||
/>
|
||||
</Box>
|
||||
</Menu>
|
||||
</>
|
||||
}
|
||||
</>
|
||||
fieldNamesToHide={[...(defaultQuickFilterFieldNames ?? []), ...(quickFilterFieldNames ?? [])]}
|
||||
placeholder="Search Fields"
|
||||
buttonProps={{sx: quickFilterButtonStyles, startIcon: (<Icon>add</Icon>)}}
|
||||
buttonChildren={"Add Filter"}
|
||||
isModeSelectOne={true}
|
||||
handleSelectedField={handleFieldListMenuSelection}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
</Box>
|
||||
<Box>
|
||||
{sortMenuComponent}
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// advanced mode - 2 rows - one for Filter Builder button & sort control, 2nd row for the filter-detail box //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
metaData && tableMetaData && mode == "advanced" &&
|
||||
<>
|
||||
<Tooltip enterDelay={500} title="Build an advanced Filter" placement="top">
|
||||
<Button onClick={(e) => openFilterBuilder(e)} startIcon={<Badge badgeContent={countValidCriteria(queryFilter)} color="warning" sx={{"& .MuiBadge-badge": {color: "#FFFFFF"}}} anchorOrigin={{vertical: "top", horizontal: "left"}}><Icon>filter_list</Icon></Badge>} sx={{width: "180px", minWidth: "180px", border: "1px solid gray"}}>
|
||||
Filter Builder
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<div id="clearFiltersButton" style={{display: "inline-block", position: "relative", top: "2px", left: "-1.5rem", width: "1rem"}}>
|
||||
{
|
||||
hasValidFilters && (
|
||||
<Box borderRadius="0.75rem" border={`1px solid ${borderGray}`}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Box p="0.5rem">
|
||||
<Tooltip enterDelay={500} title="Build an advanced Filter" placement="top">
|
||||
<>
|
||||
<Tooltip title="Clear Filter">
|
||||
<Icon sx={{cursor: "pointer"}} onClick={() => setShowClearFiltersWarning(true)}>clear</Icon>
|
||||
</Tooltip>
|
||||
<Dialog open={showClearFiltersWarning} onClose={() => setShowClearFiltersWarning(true)} onKeyPress={(e) => handleClearFiltersAction(e)}>
|
||||
<DialogTitle id="alert-dialog-title">Confirm</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>Are you sure you want to remove all conditions from the current filter?</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<QCancelButton label="No" disabled={false} onClickHandler={() => setShowClearFiltersWarning(true)} />
|
||||
<QSaveButton label="Yes" iconName="check" disabled={false} onClickHandler={() => handleClearFiltersAction(null, true)} />
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Button
|
||||
onClick={(e) => openFilterBuilder(e)}
|
||||
startIcon={<Icon>build</Icon>}
|
||||
sx={{borderRadius: "0.75rem", padding: "0.5rem", pl: "1rem", fontSize: "0.875rem", fontWeight: 500, border: `1px solid ${accentColor}`, textTransform: "none"}}
|
||||
>
|
||||
Filter Builder
|
||||
{
|
||||
countValidCriteria(queryFilter) > 0 &&
|
||||
<Box sx={{backgroundColor: accentColor, marginLeft: "0.25rem", minWidth: "1rem", fontSize: "0.75rem"}} borderRadius="50%" color="#FFFFFF" position="relative" top="-2px">
|
||||
{countValidCriteria(queryFilter) }
|
||||
</Box>
|
||||
}
|
||||
</Button>
|
||||
{
|
||||
hasValidFilters && <XIcon shade="accent" position="default" onClick={() => setShowClearFiltersWarning(true)} />
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<Box sx={{fontSize: "1rem"}} whiteSpace="nowrap" display="flex" ml={0.25} flexShrink={1} flexGrow={1} alignItems="center">
|
||||
Current Filter:
|
||||
</Tooltip>
|
||||
<Dialog open={showClearFiltersWarning} onClose={() => setShowClearFiltersWarning(false)} onKeyPress={(e) => handleClearFiltersAction(e)}>
|
||||
<DialogTitle id="alert-dialog-title">Confirm</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>Are you sure you want to remove all conditions from the current filter?</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<QCancelButton label="No" disabled={false} onClickHandler={() => setShowClearFiltersWarning(false)} />
|
||||
<QSaveButton label="Yes" iconName="check" disabled={false} onClickHandler={() => handleClearFiltersAction(null, true)} />
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
<Box pr={"0.5rem"}>
|
||||
{sortMenuComponent}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box whiteSpace="nowrap" display="flex" flexShrink={1} flexGrow={1} alignItems="center">
|
||||
{
|
||||
<Box display="inline-block" border="1px solid gray" borderRadius="0.5rem" whiteSpace="nowrap" overflow="hidden" textOverflow="ellipsis" width="100px" flexShrink={1} flexGrow={1} sx={{fontSize: "1rem"}} minHeight={"2rem"} p={0.25} ml={0.5}>
|
||||
<Box
|
||||
display="inline-block"
|
||||
borderTop={`1px solid ${borderGray}`}
|
||||
borderRadius="0 0 0.75rem 0.75rem"
|
||||
width="100%"
|
||||
sx={{fontSize: "1rem", background: "#FFFFFF"}}
|
||||
minHeight={"2.5rem"}
|
||||
p={"0.5rem"}
|
||||
pb={0} // comes from the elements inside
|
||||
boxShadow={"inset 0px 0px 4px 2px #EFEFED"}
|
||||
>
|
||||
{queryToAdvancedString()}
|
||||
</Box>
|
||||
}
|
||||
</Box>
|
||||
</>
|
||||
}
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center">
|
||||
{
|
||||
metaData && tableMetaData &&
|
||||
<Box px={1} display="flex" alignItems="center">
|
||||
<Tooltip title={reasonWhyBasicIsDisabled}>
|
||||
<ToggleButtonGroup
|
||||
value={mode}
|
||||
exclusive
|
||||
onChange={(event, newValue) => modeToggleClicked(newValue)}
|
||||
size="small"
|
||||
sx={{pl: 0.5, width: "10rem"}}
|
||||
>
|
||||
<ToggleButton value="basic" disabled={!canFilterWorkAsBasic}>Basic</ToggleButton>
|
||||
<ToggleButton value="advanced">Advanced</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
</Box>
|
||||
@ -668,4 +805,4 @@ export function getDefaultQuickFilterFieldNames(table: QTableMetaData): string[]
|
||||
return (defaultQuickFilterFieldNames);
|
||||
}
|
||||
|
||||
export default BasicAndAdvancedQueryControls;
|
||||
export default BasicAndAdvancedQueryControls;
|
||||
|
726
src/qqq/components/query/FieldListMenu.tsx
Normal file
726
src/qqq/components/query/FieldListMenu.tsx
Normal file
@ -0,0 +1,726 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2024. 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 {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import List from "@mui/material/List/List";
|
||||
import ListItem, {ListItemProps} from "@mui/material/ListItem/ListItem";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import React, {useState} from "react";
|
||||
|
||||
interface FieldListMenuProps
|
||||
{
|
||||
idPrefix: string;
|
||||
heading?: string;
|
||||
placeholder?: string;
|
||||
tableMetaData: QTableMetaData;
|
||||
showTableHeaderEvenIfNoExposedJoins: boolean;
|
||||
fieldNamesToHide?: string[];
|
||||
buttonProps: any;
|
||||
buttonChildren: JSX.Element | string;
|
||||
|
||||
isModeSelectOne?: boolean;
|
||||
handleSelectedField?: (field: QFieldMetaData, table: QTableMetaData) => void;
|
||||
|
||||
isModeToggle?: boolean;
|
||||
toggleStates?: {[fieldName: string]: boolean};
|
||||
handleToggleField?: (field: QFieldMetaData, table: QTableMetaData, newValue: boolean) => void;
|
||||
|
||||
fieldEndAdornment?: JSX.Element
|
||||
handleAdornmentClick?: (field: QFieldMetaData, table: QTableMetaData, event: React.MouseEvent<any>) => void;
|
||||
}
|
||||
|
||||
FieldListMenu.defaultProps = {
|
||||
showTableHeaderEvenIfNoExposedJoins: false,
|
||||
isModeSelectOne: false,
|
||||
isModeToggle: false,
|
||||
};
|
||||
|
||||
interface TableWithFields
|
||||
{
|
||||
table?: QTableMetaData;
|
||||
fields: QFieldMetaData[];
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Component to render a list of fields from a table (and its join tables)
|
||||
** which can be interacted with...
|
||||
*******************************************************************************/
|
||||
export default function FieldListMenu({idPrefix, heading, placeholder, tableMetaData, showTableHeaderEvenIfNoExposedJoins, buttonProps, buttonChildren, isModeSelectOne, fieldNamesToHide, handleSelectedField, isModeToggle, toggleStates, handleToggleField, fieldEndAdornment, handleAdornmentClick}: FieldListMenuProps): JSX.Element
|
||||
{
|
||||
const [menuAnchorElement, setMenuAnchorElement] = useState(null);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [focusedIndex, setFocusedIndex] = useState(null as number);
|
||||
|
||||
const [fieldsByTable, setFieldsByTable] = useState([] as TableWithFields[]);
|
||||
const [collapsedTables, setCollapsedTables] = useState({} as {[tableName: string]: boolean});
|
||||
|
||||
const [lastMouseOverXY, setLastMouseOverXY] = useState({x: 0, y: 0});
|
||||
const [timeOfLastArrow, setTimeOfLastArrow] = useState(0)
|
||||
|
||||
//////////////////
|
||||
// check usages //
|
||||
//////////////////
|
||||
if(isModeSelectOne)
|
||||
{
|
||||
if(!handleSelectedField)
|
||||
{
|
||||
throw("In FieldListMenu, if isModeSelectOne=true, then a callback for handleSelectedField must be provided.");
|
||||
}
|
||||
}
|
||||
|
||||
if(isModeToggle)
|
||||
{
|
||||
if(!toggleStates)
|
||||
{
|
||||
throw("In FieldListMenu, if isModeToggle=true, then a model for toggleStates must be provided.");
|
||||
}
|
||||
if(!handleToggleField)
|
||||
{
|
||||
throw("In FieldListMenu, if isModeToggle=true, then a callback for handleToggleField must be provided.");
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////
|
||||
// init some stuff //
|
||||
/////////////////////
|
||||
if (fieldsByTable.length == 0)
|
||||
{
|
||||
collapsedTables[tableMetaData.name] = false;
|
||||
|
||||
if (tableMetaData.exposedJoins?.length > 0)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if we have exposed joins, put the table meta data with its fields, and then all of the join tables & fields too //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
fieldsByTable.push({table: tableMetaData, fields: getTableFieldsAsAlphabeticalArray(tableMetaData)});
|
||||
|
||||
for (let i = 0; i < tableMetaData.exposedJoins?.length; i++)
|
||||
{
|
||||
const joinTable = tableMetaData.exposedJoins[i].joinTable;
|
||||
fieldsByTable.push({table: joinTable, fields: getTableFieldsAsAlphabeticalArray(joinTable)});
|
||||
|
||||
collapsedTables[joinTable.name] = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// no exposed joins - just the table (w/o its meta-data) //
|
||||
///////////////////////////////////////////////////////////
|
||||
fieldsByTable.push({fields: getTableFieldsAsAlphabeticalArray(tableMetaData)});
|
||||
}
|
||||
|
||||
setFieldsByTable(fieldsByTable);
|
||||
setCollapsedTables(collapsedTables);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function getTableFieldsAsAlphabeticalArray(table: QTableMetaData): QFieldMetaData[]
|
||||
{
|
||||
const fields: QFieldMetaData[] = [];
|
||||
table.fields.forEach(field =>
|
||||
{
|
||||
let fullFieldName = field.name;
|
||||
if(table.name != tableMetaData.name)
|
||||
{
|
||||
fullFieldName = `${table.name}.${field.name}`;
|
||||
}
|
||||
|
||||
if(fieldNamesToHide && fieldNamesToHide.indexOf(fullFieldName) > -1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
fields.push(field)
|
||||
});
|
||||
fields.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return (fields);
|
||||
}
|
||||
|
||||
const fieldsByTableToShow: TableWithFields[] = [];
|
||||
let maxFieldIndex = 0;
|
||||
fieldsByTable.forEach((tableWithFields) =>
|
||||
{
|
||||
let fieldsToShowForThisTable = tableWithFields.fields.filter(doesFieldMatchSearchText);
|
||||
if (fieldsToShowForThisTable.length > 0)
|
||||
{
|
||||
fieldsByTableToShow.push({table: tableWithFields.table, fields: fieldsToShowForThisTable});
|
||||
maxFieldIndex += fieldsToShowForThisTable.length;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function getShownFieldAndTableByIndex(targetIndex: number): {field: QFieldMetaData, table: QTableMetaData}
|
||||
{
|
||||
let index = -1;
|
||||
for (let i = 0; i < fieldsByTableToShow.length; i++)
|
||||
{
|
||||
const tableWithField = fieldsByTableToShow[i];
|
||||
for (let j = 0; j < tableWithField.fields.length; j++)
|
||||
{
|
||||
index++;
|
||||
|
||||
if(index == targetIndex)
|
||||
{
|
||||
return {field: tableWithField.fields[j], table: tableWithField.table}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** event handler for keys presses
|
||||
*******************************************************************************/
|
||||
function keyDown(event: any)
|
||||
{
|
||||
// console.log(`Event key: ${event.key}`);
|
||||
setTimeout(() => document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus());
|
||||
|
||||
if(isModeSelectOne && event.key == "Enter" && focusedIndex != null)
|
||||
{
|
||||
setTimeout(() =>
|
||||
{
|
||||
event.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
const {field, table} = getShownFieldAndTableByIndex(focusedIndex);
|
||||
if (field)
|
||||
{
|
||||
handleSelectedField(field, table ?? tableMetaData);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const keyOffsetMap: { [key: string]: number } = {
|
||||
"End": 10000,
|
||||
"Home": -10000,
|
||||
"ArrowDown": 1,
|
||||
"ArrowUp": -1,
|
||||
"PageDown": 5,
|
||||
"PageUp": -5,
|
||||
};
|
||||
|
||||
const offset = keyOffsetMap[event.key];
|
||||
if (offset)
|
||||
{
|
||||
event.stopPropagation();
|
||||
setTimeOfLastArrow(new Date().getTime());
|
||||
|
||||
if (isModeSelectOne)
|
||||
{
|
||||
let startIndex = focusedIndex;
|
||||
if (offset > 0)
|
||||
{
|
||||
/////////////////
|
||||
// a down move //
|
||||
/////////////////
|
||||
if(startIndex == null)
|
||||
{
|
||||
startIndex = -1;
|
||||
}
|
||||
|
||||
let goalIndex = startIndex + offset;
|
||||
if(goalIndex > maxFieldIndex - 1)
|
||||
{
|
||||
goalIndex = maxFieldIndex - 1;
|
||||
}
|
||||
|
||||
doSetFocusedIndex(goalIndex, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
////////////////
|
||||
// an up move //
|
||||
////////////////
|
||||
let goalIndex = startIndex + offset;
|
||||
if(goalIndex < 0)
|
||||
{
|
||||
goalIndex = 0;
|
||||
}
|
||||
|
||||
doSetFocusedIndex(goalIndex, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function doSetFocusedIndex(i: number, tryToScrollIntoView: boolean): void
|
||||
{
|
||||
if (isModeSelectOne)
|
||||
{
|
||||
setFocusedIndex(i);
|
||||
console.log(`Setting index to ${i}`);
|
||||
|
||||
if (tryToScrollIntoView)
|
||||
{
|
||||
const element = document.getElementById(`field-list-dropdown-${idPrefix}-${i}`);
|
||||
element?.scrollIntoView({block: "center"});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function setFocusedField(field: QFieldMetaData, table: QTableMetaData, tryToScrollIntoView: boolean)
|
||||
{
|
||||
let index = -1;
|
||||
for (let i = 0; i < fieldsByTableToShow.length; i++)
|
||||
{
|
||||
const tableWithField = fieldsByTableToShow[i];
|
||||
for (let j = 0; j < tableWithField.fields.length; j++)
|
||||
{
|
||||
const loopField = tableWithField.fields[j];
|
||||
index++;
|
||||
|
||||
const tableMatches = (table == null || table.name == tableWithField.table.name);
|
||||
if (tableMatches && field.name == loopField.name)
|
||||
{
|
||||
doSetFocusedIndex(index, tryToScrollIntoView);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** event handler for mouse-over the menu
|
||||
*******************************************************************************/
|
||||
function handleMouseOver(event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLDivElement> | React.MouseEvent<HTMLLIElement>, field: QFieldMetaData, table: QTableMetaData)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// so we're trying to fix the case where, if you put your mouse over an element, but then press up/down arrows, //
|
||||
// where the mouse will become over a different element after the scroll, and the focus will follow the mouse instead of keyboard. //
|
||||
// the last x/y isn't really useful, because the mouse generally isn't left exactly where it was when the mouse-over happened (edge of the element) //
|
||||
// but the keyboard last-arrow time that we capture, that's what's actually being useful in here //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(event.clientX == lastMouseOverXY.x && event.clientY == lastMouseOverXY.y)
|
||||
{
|
||||
// console.log("mouse didn't move, so, doesn't count");
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().getTime();
|
||||
// console.log(`Compare now [${now}] to last arrow [${timeOfLastArrow}] (diff: [${now - timeOfLastArrow}])`);
|
||||
if(now < timeOfLastArrow + 300)
|
||||
{
|
||||
// console.log("An arrow event happened less than 300 mills ago, so doesn't count.");
|
||||
return;
|
||||
}
|
||||
|
||||
// console.log("yay, mouse over...");
|
||||
setFocusedField(field, table, false);
|
||||
setLastMouseOverXY({x: event.clientX, y: event.clientY});
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** event handler for text input changes
|
||||
*******************************************************************************/
|
||||
function updateSearch(event: React.ChangeEvent<HTMLInputElement>)
|
||||
{
|
||||
setSearchText(event?.target?.value ?? "");
|
||||
doSetFocusedIndex(0, true);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function doesFieldMatchSearchText(field: QFieldMetaData): boolean
|
||||
{
|
||||
if (searchText == "")
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
const columnLabelMinusTable = field.label.replace(/.*: /, "");
|
||||
if (columnLabelMinusTable.toLowerCase().startsWith(searchText.toLowerCase()))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
////////////////////////////////////////////////////////////
|
||||
// try to match word-boundary followed by the filter text //
|
||||
// e.g., "name" would match "First Name" or "Last Name" //
|
||||
////////////////////////////////////////////////////////////
|
||||
const re = new RegExp("\\b" + searchText.toLowerCase());
|
||||
if (columnLabelMinusTable.toLowerCase().match(re))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// in case text is an invalid regex... well, at least do a starts-with match... //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
if (columnLabelMinusTable.toLowerCase().startsWith(searchText.toLowerCase()))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
|
||||
const tableLabel = field.label.replace(/:.*/, "");
|
||||
if (tableLabel)
|
||||
{
|
||||
try
|
||||
{
|
||||
////////////////////////////////////////////////////////////
|
||||
// try to match word-boundary followed by the filter text //
|
||||
// e.g., "name" would match "First Name" or "Last Name" //
|
||||
////////////////////////////////////////////////////////////
|
||||
const re = new RegExp("\\b" + searchText.toLowerCase());
|
||||
if (tableLabel.toLowerCase().match(re))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// in case text is an invalid regex... well, at least do a starts-with match... //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
if (tableLabel.toLowerCase().startsWith(searchText.toLowerCase()))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function openMenu(event: any)
|
||||
{
|
||||
setFocusedIndex(null);
|
||||
setMenuAnchorElement(event.currentTarget);
|
||||
setTimeout(() =>
|
||||
{
|
||||
document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus();
|
||||
doSetFocusedIndex(0, true);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function closeMenu()
|
||||
{
|
||||
setMenuAnchorElement(null);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Event handler for toggling a field in toggle mode
|
||||
*******************************************************************************/
|
||||
function handleFieldToggle(event: React.ChangeEvent<HTMLInputElement>, field: QFieldMetaData, table: QTableMetaData)
|
||||
{
|
||||
event.stopPropagation();
|
||||
handleToggleField(field, table, event.target.checked);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Event handler for toggling a table in toggle mode
|
||||
*******************************************************************************/
|
||||
function handleTableToggle(event: React.ChangeEvent<HTMLInputElement>, table: QTableMetaData)
|
||||
{
|
||||
event.stopPropagation();
|
||||
|
||||
const fieldsList = [...table.fields.values()];
|
||||
for (let i = 0; i < fieldsList.length; i++)
|
||||
{
|
||||
const field = fieldsList[i];
|
||||
if(doesFieldMatchSearchText(field))
|
||||
{
|
||||
handleToggleField(field, table, event.target.checked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////
|
||||
// compute the table-level toggle state & count values //
|
||||
/////////////////////////////////////////////////////////
|
||||
const tableToggleStates: {[tableName: string]: boolean} = {};
|
||||
const tableToggleCounts: {[tableName: string]: number} = {};
|
||||
|
||||
if(isModeToggle)
|
||||
{
|
||||
const {allOn, count} = getTableToggleState(tableMetaData, true);
|
||||
tableToggleStates[tableMetaData.name] = allOn;
|
||||
tableToggleCounts[tableMetaData.name] = count;
|
||||
|
||||
for (let i = 0; i < tableMetaData.exposedJoins?.length; i++)
|
||||
{
|
||||
const join = tableMetaData.exposedJoins[i];
|
||||
const {allOn, count} = getTableToggleState(join.joinTable, false);
|
||||
tableToggleStates[join.joinTable.name] = allOn;
|
||||
tableToggleCounts[join.joinTable.name] = count;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function getTableToggleState(table: QTableMetaData, isMainTable: boolean): {allOn: boolean, count: number}
|
||||
{
|
||||
const fieldsList = [...table.fields.values()];
|
||||
let allOn = true;
|
||||
let count = 0;
|
||||
for (let i = 0; i < fieldsList.length; i++)
|
||||
{
|
||||
const field = fieldsList[i];
|
||||
const name = isMainTable ? field.name : `${table.name}.${field.name}`;
|
||||
if(!toggleStates[name])
|
||||
{
|
||||
allOn = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return ({allOn: allOn, count: count});
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function toggleCollapsedTable(tableName: string)
|
||||
{
|
||||
collapsedTables[tableName] = !collapsedTables[tableName]
|
||||
setCollapsedTables(Object.assign({}, collapsedTables));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function doHandleAdornmentClick(field: QFieldMetaData, table: QTableMetaData, event: React.MouseEvent<any>)
|
||||
{
|
||||
console.log("In doHandleAdornmentClick");
|
||||
closeMenu();
|
||||
handleAdornmentClick(field, table, event);
|
||||
}
|
||||
|
||||
|
||||
let index = -1;
|
||||
const textFieldId = `field-list-dropdown-${idPrefix}-textField`;
|
||||
let listItemPadding = isModeToggle ? "0.125rem": "0.5rem";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={openMenu} {...buttonProps}>
|
||||
{buttonChildren}
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={menuAnchorElement}
|
||||
anchorOrigin={{vertical: "bottom", horizontal: "left"}}
|
||||
transformOrigin={{vertical: "top", horizontal: "left"}}
|
||||
open={menuAnchorElement != null}
|
||||
onClose={closeMenu}
|
||||
onKeyDown={keyDown} // this is added here so arrow-key-up/down events don't make the whole menu become "focused" (blue outline). it works.
|
||||
keepMounted
|
||||
>
|
||||
<Box width={isModeToggle ? "305px" : "265px"} borderRadius={2} className={`fieldListMenuBody fieldListMenuBody-${idPrefix}`}>
|
||||
{
|
||||
heading &&
|
||||
<Box px={1} py={0.5} fontWeight={"700"}>
|
||||
{heading}
|
||||
</Box>
|
||||
}
|
||||
<Box p={1} pt={0.5}>
|
||||
<TextField id={textFieldId} variant="outlined" placeholder={placeholder ?? "Search Fields"} fullWidth value={searchText} onChange={updateSearch} onKeyDown={keyDown} inputProps={{sx: {pr: "2rem"}}} />
|
||||
{
|
||||
searchText != "" && <IconButton sx={{position: "absolute", right: "0.5rem", top: "0.5rem"}} onClick={() =>
|
||||
{
|
||||
updateSearch(null);
|
||||
document.getElementById(textFieldId).focus();
|
||||
}}><Icon fontSize="small">close</Icon></IconButton>
|
||||
}
|
||||
</Box>
|
||||
<Box maxHeight={"445px"} overflow="auto" mr={"-0.5rem"} sx={{scrollbarGutter: "stable"}}>
|
||||
<List sx={{px: "0.5rem", cursor: "default"}}>
|
||||
{
|
||||
fieldsByTableToShow.map((tableWithFields) =>
|
||||
{
|
||||
let headerContents = null;
|
||||
const headerTable = tableWithFields.table || tableMetaData;
|
||||
if(tableWithFields.table || showTableHeaderEvenIfNoExposedJoins)
|
||||
{
|
||||
headerContents = (<b>{headerTable.label} Fields</b>);
|
||||
}
|
||||
|
||||
if(isModeToggle)
|
||||
{
|
||||
headerContents = (<FormControlLabel
|
||||
sx={{display: "flex", alignItems: "flex-start", "& .MuiFormControlLabel-label": {lineHeight: "1.4", fontWeight: "500 !important"}}}
|
||||
control={<Switch
|
||||
size="small"
|
||||
sx={{top: "1px"}}
|
||||
checked={tableToggleStates[headerTable.name]}
|
||||
onChange={(event) => handleTableToggle(event, headerTable)}
|
||||
/>}
|
||||
label={<span style={{marginTop: "0.25rem", display: "inline-block"}}><b>{headerTable.label} Fields</b> <span style={{fontWeight: 400}}>({tableToggleCounts[headerTable.name]})</span></span>} />)
|
||||
}
|
||||
|
||||
if(isModeToggle)
|
||||
{
|
||||
headerContents = (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={() => toggleCollapsedTable(headerTable.name)}
|
||||
sx={{justifyContent: "flex-start", fontSize: "0.875rem", pt: 0.5, pb: 0, mr: "0.25rem"}}
|
||||
disableRipple={true}
|
||||
>
|
||||
<Icon sx={{fontSize: "1.5rem !important", position: "relative", top: "2px"}}>{collapsedTables[headerTable.name] ? "expand_less" : "expand_more"}</Icon>
|
||||
</IconButton>
|
||||
{headerContents}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
let marginLeft = "unset";
|
||||
if(isModeToggle)
|
||||
{
|
||||
marginLeft = "-1rem";
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={tableWithFields.table?.name ?? "theTable"}>
|
||||
<>
|
||||
{headerContents && <ListItem sx={{position: "sticky", top: "0", backgroundColor: "#FFFFFF", zIndex: 1, padding: listItemPadding, ml: marginLeft, display: "flex", alignItems: "flex-start"}}>{headerContents}</ListItem>}
|
||||
{
|
||||
tableWithFields.fields.map((field) =>
|
||||
{
|
||||
index++;
|
||||
const key = `${tableWithFields.table?.name}-${field.name}`
|
||||
|
||||
if(collapsedTables[headerTable.name])
|
||||
{
|
||||
return (<React.Fragment key={key} />);
|
||||
}
|
||||
|
||||
let style = {};
|
||||
if (index == focusedIndex)
|
||||
{
|
||||
style = {backgroundColor: "#EFEFEF"};
|
||||
}
|
||||
|
||||
const onClick: ListItemProps = {};
|
||||
if (isModeSelectOne)
|
||||
{
|
||||
onClick.onClick = () =>
|
||||
{
|
||||
closeMenu();
|
||||
handleSelectedField(field, tableWithFields.table ?? tableMetaData);
|
||||
}
|
||||
}
|
||||
|
||||
let label: JSX.Element | string = field.label;
|
||||
const fullFieldName = tableWithFields.table && tableWithFields.table.name != tableMetaData.name ? `${tableWithFields.table.name}.${field.name}` : field.name;
|
||||
|
||||
if(fieldEndAdornment)
|
||||
{
|
||||
label = <Box width="100%" display="inline-flex" justifyContent="space-between">
|
||||
{label}
|
||||
<Box onClick={(event) => doHandleAdornmentClick(field, tableWithFields.table, event)}>
|
||||
{fieldEndAdornment}
|
||||
</Box>
|
||||
</Box>;
|
||||
}
|
||||
|
||||
let contents = <>{label}</>;
|
||||
let paddingLeft = "0.5rem";
|
||||
|
||||
if (isModeToggle)
|
||||
{
|
||||
contents = (<FormControlLabel
|
||||
sx={{display: "flex", alignItems: "flex-start", "& .MuiFormControlLabel-label": {lineHeight: "1.4", color: "#606060", fontWeight: "500 !important"}}}
|
||||
control={<Switch
|
||||
size="small"
|
||||
sx={{top: "-3px"}}
|
||||
checked={toggleStates[fullFieldName]}
|
||||
onChange={(event) => handleFieldToggle(event, field, tableWithFields.table)}
|
||||
/>}
|
||||
label={label} />);
|
||||
paddingLeft = "2.5rem";
|
||||
}
|
||||
|
||||
return <ListItem
|
||||
key={key}
|
||||
id={`field-list-dropdown-${idPrefix}-${index}`}
|
||||
sx={{color: "#757575", p: 1, borderRadius: ".5rem", padding: listItemPadding, pl: paddingLeft, scrollMarginTop: "3rem", ...style}}
|
||||
onMouseOver={(event) => handleMouseOver(event, field, tableWithFields.table)}
|
||||
{...onClick}
|
||||
>{contents}</ListItem>;
|
||||
})
|
||||
}
|
||||
</>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
}
|
||||
{
|
||||
index == -1 && <ListItem sx={{p: "0.5rem"}}><i>No fields found.</i></ListItem>
|
||||
}
|
||||
</List>
|
||||
</Box>
|
||||
</Box>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
@ -203,7 +203,7 @@ FilterCriteriaRow.defaultProps =
|
||||
{
|
||||
};
|
||||
|
||||
export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue: OperatorOption): {criteriaIsValid: boolean, criteriaStatusTooltip: string}
|
||||
export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue?: OperatorOption): {criteriaIsValid: boolean, criteriaStatusTooltip: string}
|
||||
{
|
||||
let criteriaIsValid = true;
|
||||
let criteriaStatusTooltip = "This condition is fully defined and is part of your filter.";
|
||||
|
@ -37,6 +37,7 @@ import QContext from "QContext";
|
||||
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
|
||||
import {getDefaultCriteriaValue, getOperatorOptions, OperatorOption, validateCriteria} from "qqq/components/query/FilterCriteriaRow";
|
||||
import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues";
|
||||
import XIcon from "qqq/components/query/XIcon";
|
||||
import FilterUtils from "qqq/utils/qqq/FilterUtils";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
|
||||
@ -62,8 +63,17 @@ QuickFilter.defaultProps =
|
||||
let seedId = new Date().getTime() % 173237;
|
||||
|
||||
export const quickFilterButtonStyles = {
|
||||
fontSize: "0.75rem", color: "#757575", textTransform: "none", borderRadius: "2rem", border: "1px solid #757575",
|
||||
minWidth: "3.5rem", minHeight: "auto", padding: "0.375rem 0.625rem", whiteSpace: "nowrap"
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
color: "#757575",
|
||||
textTransform: "none",
|
||||
borderRadius: "2rem",
|
||||
border: "1px solid #757575",
|
||||
minWidth: "3.5rem",
|
||||
minHeight: "auto",
|
||||
padding: "0.375rem 0.625rem",
|
||||
whiteSpace: "nowrap",
|
||||
marginBottom: "0.5rem"
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@ -439,23 +449,6 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// only show the 'x' if it's to clear out a valid criteria on the field, //
|
||||
// or if we were given a callback to remove the quick-filter field from the screen //
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
let xIcon = <span />;
|
||||
if(criteriaIsValid || handleRemoveQuickFilterField)
|
||||
{
|
||||
xIcon = <span style={{position: "relative"}}><IconButton sx={{
|
||||
fontSize: "0.75rem",
|
||||
border: "1px solid gray",
|
||||
padding: "0",
|
||||
background: "#f0f2f5 !important",
|
||||
position: "absolute",
|
||||
left: "-1.125rem",
|
||||
}} onClick={xClicked}><Icon>close</Icon></IconButton></span>
|
||||
}
|
||||
|
||||
//////////////////////////////
|
||||
// return the button & menu //
|
||||
//////////////////////////////
|
||||
@ -463,7 +456,13 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
return (
|
||||
<>
|
||||
{button}
|
||||
{xIcon}
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// only show the 'x' if it's to clear out a valid criteria on the field, //
|
||||
// or if we were given a callback to remove the quick-filter field from the screen //
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
(criteriaIsValid || handleRemoveQuickFilterField) && <XIcon shade={criteriaIsValid ? "accent" : "default"} position="forQuickFilter" onClick={xClicked} />
|
||||
}
|
||||
{
|
||||
isOpen && <Menu open={Boolean(anchorEl)} anchorEl={anchorEl} onClose={closeMenu} sx={{overflow: "visible"}}>
|
||||
<Box display="inline-block" width={widthAndMaxWidth} maxWidth={widthAndMaxWidth} className="operatorColumn">
|
||||
@ -488,7 +487,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
operatorOption={operatorSelectedValue}
|
||||
criteria={criteria}
|
||||
field={fieldMetaData}
|
||||
table={tableMetaData} // todo - joins?
|
||||
table={tableForField}
|
||||
valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)}
|
||||
initiallyOpenMultiValuePvs={true} // todo - maybe not?
|
||||
/>
|
||||
|
92
src/qqq/components/query/XIcon.tsx
Normal file
92
src/qqq/components/query/XIcon.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2024. 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 Icon from "@mui/material/Icon";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import React, {useContext} from "react";
|
||||
import QContext from "QContext";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
|
||||
interface XIconProps
|
||||
{
|
||||
onClick: (e: React.MouseEvent<HTMLSpanElement>) => void;
|
||||
position: "forQuickFilter" | "forAdvancedQueryPreview" | "default";
|
||||
shade: "default" | "accent" | "accentLight"
|
||||
}
|
||||
|
||||
XIcon.defaultProps = {
|
||||
position: "default",
|
||||
shade: "default"
|
||||
};
|
||||
|
||||
export default function XIcon({onClick, position, shade}: XIconProps): JSX.Element
|
||||
{
|
||||
const {accentColor, accentColorLight} = useContext(QContext)
|
||||
|
||||
//////////////////////////
|
||||
// for default position //
|
||||
//////////////////////////
|
||||
let rest: any = {
|
||||
top: "-0.75rem",
|
||||
left: "-0.5rem",
|
||||
}
|
||||
|
||||
if(position == "forQuickFilter")
|
||||
{
|
||||
rest = {
|
||||
left: "-1.125rem",
|
||||
}
|
||||
}
|
||||
else if(position == "forAdvancedQueryPreview")
|
||||
{
|
||||
rest = {
|
||||
top: "-0.375rem",
|
||||
left: "-0.75rem",
|
||||
}
|
||||
}
|
||||
|
||||
let color;
|
||||
switch (shade)
|
||||
{
|
||||
case "default":
|
||||
color = colors.gray.main;
|
||||
break;
|
||||
case "accent":
|
||||
color = accentColor;
|
||||
break;
|
||||
case "accentLight":
|
||||
color = accentColorLight;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<span style={{position: "relative"}}><IconButton sx={{
|
||||
fontSize: "0.75rem",
|
||||
border: `1px solid ${color}`,
|
||||
color: color,
|
||||
padding: "0",
|
||||
background: "#FFFFFF !important",
|
||||
position: "absolute",
|
||||
... rest
|
||||
}} onClick={onClick}><Icon>close</Icon></IconButton></span>
|
||||
)
|
||||
}
|
@ -114,6 +114,59 @@ export default class QQueryColumns
|
||||
return fields;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public getVisibleColumnCount(): number
|
||||
{
|
||||
let rs = 0;
|
||||
for (let i = 0; i < this.columns.length; i++)
|
||||
{
|
||||
if(this.columns[i].name == "__check__")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if(this.columns[i].isVisible)
|
||||
{
|
||||
rs++;
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public getVisibilityToggleStates(): { [name: string]: boolean }
|
||||
{
|
||||
const rs: {[name: string]: boolean} = {};
|
||||
for (let i = 0; i < this.columns.length; i++)
|
||||
{
|
||||
rs[this.columns[i].name] = this.columns[i].isVisible;
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public setIsVisible(name: string, isVisible: boolean)
|
||||
{
|
||||
for (let i = 0; i < this.columns.length; i++)
|
||||
{
|
||||
if(this.columns[i].name == name)
|
||||
{
|
||||
this.columns[i].isVisible = isVisible;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
@ -33,7 +33,7 @@ import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QC
|
||||
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 {Alert, Collapse, Typography} from "@mui/material";
|
||||
import {Alert, Collapse, Menu, Typography} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Card from "@mui/material/Card";
|
||||
@ -44,21 +44,22 @@ import LinearProgress from "@mui/material/LinearProgress";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Modal from "@mui/material/Modal";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import {ColumnHeaderFilterIconButtonProps, DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnResizeParams, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridFilterMenuItem, GridPinnedColumns, gridPreferencePanelStateSelector, GridPreferencePanelsValue, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridApiRef, useGridSelector} from "@mui/x-data-grid-pro";
|
||||
import {ColumnHeaderFilterIconButtonProps, DataGridPro, GridCallbackDetails, GridColDef, GridColumnHeaderParams, GridColumnHeaderSortIconProps, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnResizeParams, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridFilterMenuItem, GridPinnedColumns, gridPreferencePanelStateSelector, GridPreferencePanelsValue, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridApiRef, useGridSelector} from "@mui/x-data-grid-pro";
|
||||
import {GridRowModel} from "@mui/x-data-grid/models/gridRows";
|
||||
import FormData from "form-data";
|
||||
import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react";
|
||||
import {useLocation, useNavigate, useSearchParams} from "react-router-dom";
|
||||
import QContext from "QContext";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import {QCancelButton, QCreateNewButton} from "qqq/components/buttons/DefaultButtons";
|
||||
import MenuButton from "qqq/components/buttons/MenuButton";
|
||||
import {GotoRecordButton} from "qqq/components/misc/GotoRecordDialog";
|
||||
import SavedViews from "qqq/components/misc/SavedViews";
|
||||
import BasicAndAdvancedQueryControls from "qqq/components/query/BasicAndAdvancedQueryControls";
|
||||
import {CustomColumnsPanel} from "qqq/components/query/CustomColumnsPanel";
|
||||
import {CustomFilterPanel} from "qqq/components/query/CustomFilterPanel";
|
||||
import CustomPaginationComponent from "qqq/components/query/CustomPaginationComponent";
|
||||
import ExportMenuItem from "qqq/components/query/ExportMenuItem";
|
||||
import FieldListMenu from "qqq/components/query/FieldListMenu";
|
||||
import {validateCriteria} from "qqq/components/query/FilterCriteriaRow";
|
||||
import QueryScreenActionMenu from "qqq/components/query/QueryScreenActionMenu";
|
||||
import SelectionSubsetDialog from "qqq/components/query/SelectionSubsetDialog";
|
||||
@ -74,6 +75,7 @@ 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 {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
|
||||
@ -246,14 +248,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const [pageState, setPageState] = useState("initial" as PageState)
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// state used by the custom column-chooser panel //
|
||||
///////////////////////////////////////////////////
|
||||
const initialColumnChooserOpenGroups = {} as { [name: string]: boolean };
|
||||
initialColumnChooserOpenGroups[tableName] = true;
|
||||
const [columnChooserOpenGroups, setColumnChooserOpenGroups] = useState(initialColumnChooserOpenGroups);
|
||||
const [columnChooserFilterText, setColumnChooserFilterText] = useState("");
|
||||
|
||||
/////////////////////////////////
|
||||
// meta-data and derived state //
|
||||
/////////////////////////////////
|
||||
@ -285,6 +279,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
const [currentSavedView, setCurrentSavedView] = useState(null as QRecord);
|
||||
const [viewIdInLocation, setViewIdInLocation] = useState(null as number);
|
||||
const [loadingSavedView, setLoadingSavedView] = useState(false);
|
||||
const [exportMenuAnchorElement, setExportMenuAnchorElement] = useState(null);
|
||||
const [tableDefaultView, setTableDefaultView] = useState(new RecordQueryView());
|
||||
|
||||
/////////////////////////////////////////////////////
|
||||
// state related to avoiding accidental row clicks //
|
||||
@ -342,7 +338,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
/////////////////////////////
|
||||
// page context references //
|
||||
/////////////////////////////
|
||||
const {setPageHeader, dotMenuOpen, keyboardHelpOpen} = useContext(QContext);
|
||||
const {accentColor, accentColorLight, setPageHeader, dotMenuOpen, keyboardHelpOpen} = useContext(QContext);
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// we use our own header - so clear out the context page header //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
setPageHeader(null);
|
||||
|
||||
//////////////////////
|
||||
// ole' faithful... //
|
||||
@ -416,6 +417,97 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
const prepQueryFilterForBackend = (sourceFilter: QQueryFilter) =>
|
||||
{
|
||||
const filterForBackend = new QQueryFilter([], sourceFilter.orderBys, sourceFilter.booleanOperator);
|
||||
for (let i = 0; i < sourceFilter?.criteria?.length; i++)
|
||||
{
|
||||
const criteria = sourceFilter.criteria[i];
|
||||
const {criteriaIsValid} = validateCriteria(criteria, null);
|
||||
if (criteriaIsValid)
|
||||
{
|
||||
if (criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// do this to avoid submitting an empty-string argument for blank/not-blank operators... //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, []));
|
||||
}
|
||||
else
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// else push a clone of the criteria - since it may get manipulated below (convertFilterPossibleValuesToIds) //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const [field] = FilterUtils.getField(tableMetaData, criteria.fieldName)
|
||||
filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, FilterUtils.cleanseCriteriaValueForQQQ(criteria.values, field)));
|
||||
}
|
||||
}
|
||||
}
|
||||
filterForBackend.skip = pageNumber * rowsPerPage;
|
||||
filterForBackend.limit = rowsPerPage;
|
||||
|
||||
return filterForBackend;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function openExportMenu(event: any)
|
||||
{
|
||||
setExportMenuAnchorElement(event.currentTarget);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function closeExportMenu()
|
||||
{
|
||||
setExportMenuAnchorElement(null);
|
||||
}
|
||||
|
||||
|
||||
///////////////////////////////////////////
|
||||
// build the export menu, for the header //
|
||||
///////////////////////////////////////////
|
||||
let exportMenu = <></>
|
||||
try
|
||||
{
|
||||
const exportMenuItemRestProps =
|
||||
{
|
||||
tableMetaData: tableMetaData,
|
||||
totalRecords: totalRecords,
|
||||
columnsModel: columnsModel,
|
||||
columnVisibilityModel: columnVisibilityModel,
|
||||
queryFilter: prepQueryFilterForBackend(queryFilter)
|
||||
}
|
||||
|
||||
exportMenu = (<>
|
||||
<IconButton sx={{p: 0, fontSize: "0.75rem", mb: 1, color: colors.secondary.main, fontVariationSettings: "'wght' 100"}} onClick={openExportMenu}><Icon fontSize="small">save_alt</Icon></IconButton>
|
||||
<Menu
|
||||
anchorEl={exportMenuAnchorElement}
|
||||
anchorOrigin={{vertical: "bottom", horizontal: "center"}}
|
||||
transformOrigin={{vertical: "top", horizontal: "center"}}
|
||||
open={exportMenuAnchorElement != null}
|
||||
onClose={closeExportMenu}
|
||||
sx={{top: "0.5rem"}}
|
||||
keepMounted>
|
||||
<ExportMenuItem format="csv" {...exportMenuItemRestProps} />
|
||||
<ExportMenuItem format="xlsx" {...exportMenuItemRestProps} />
|
||||
<ExportMenuItem format="json" {...exportMenuItemRestProps} />
|
||||
</Menu>
|
||||
</>);
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
console.log("Error preparing export menu for page header: " + e);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ -459,9 +551,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
|
||||
return(
|
||||
<div>
|
||||
{label}
|
||||
{label} {exportMenu}
|
||||
<CustomWidthTooltip title={tooltipHTML}>
|
||||
<IconButton sx={{p: 0, fontSize: "0.5rem", mb: 1, color: "#9f9f9f", fontVariationSettings: "'wght' 100"}}><Icon fontSize="small">emergency</Icon></IconButton>
|
||||
<IconButton sx={{ml: "0.5rem", p: 0, fontSize: "0.5rem", mb: 1, color: "#9f9f9f", fontVariationSettings: "'wght' 100"}}><Icon fontSize="small">emergency</Icon></IconButton>
|
||||
</CustomWidthTooltip>
|
||||
{tableVariant && getTableVariantHeader(tableVariant)}
|
||||
</div>);
|
||||
@ -470,7 +562,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
{
|
||||
return (
|
||||
<div>
|
||||
{label}
|
||||
{label} {exportMenu}
|
||||
{tableVariant && getTableVariantHeader(tableVariant)}
|
||||
</div>);
|
||||
}
|
||||
@ -569,7 +661,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
// in case page-state has already advanced to "ready" (e.g., and we're dealing with a user //
|
||||
// hitting back & forth between filters), then do a load of the new saved-view right here //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(pageState == "ready")
|
||||
if (pageState == "ready")
|
||||
{
|
||||
handleSavedViewChange(currentSavedViewId);
|
||||
}
|
||||
@ -593,6 +685,19 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** set the current view in state & local-storage - but do NOT update any
|
||||
** child-state data.
|
||||
*******************************************************************************/
|
||||
const doSetView = (view: RecordQueryView): void =>
|
||||
{
|
||||
setView(view);
|
||||
setViewAsJson(JSON.stringify(view));
|
||||
localStorage.setItem(viewLocalStorageKey, JSON.stringify(view));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ -607,6 +712,40 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
forceUpdate();
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** function called by columns menu to turn a column on or off
|
||||
*******************************************************************************/
|
||||
const handleChangeOneColumnVisibility = (field: QFieldMetaData, table: QTableMetaData, newValue: boolean) =>
|
||||
{
|
||||
///////////////////////////////////////
|
||||
// set the field's value in the view //
|
||||
///////////////////////////////////////
|
||||
let fieldName = field.name;
|
||||
if(table && table.name != tableMetaData.name)
|
||||
{
|
||||
fieldName = `${table.name}.${field.name}`;
|
||||
}
|
||||
|
||||
view.queryColumns.setIsVisible(fieldName, newValue)
|
||||
|
||||
/////////////////////
|
||||
// update the grid //
|
||||
/////////////////////
|
||||
setColumnVisibilityModel(queryColumns.toColumnVisibilityModel());
|
||||
|
||||
/////////////////////////////////////////////////
|
||||
// update the view (e.g., write local storage) //
|
||||
/////////////////////////////////////////////////
|
||||
doSetView(view)
|
||||
|
||||
///////////////////
|
||||
// ole' faithful //
|
||||
///////////////////
|
||||
forceUpdate();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ -656,43 +795,32 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** return array of table names that need ... added to query
|
||||
*******************************************************************************/
|
||||
const prepQueryFilterForBackend = (sourceFilter: QQueryFilter) =>
|
||||
const ensureOrderBysFromJoinTablesAreVisibleTables = (queryFilter: QQueryFilter, visibleJoinTablesParam?: Set<string>): string[] =>
|
||||
{
|
||||
const filterForBackend = new QQueryFilter([], sourceFilter.orderBys, sourceFilter.booleanOperator);
|
||||
for (let i = 0; i < sourceFilter?.criteria?.length; i++)
|
||||
const rs: string[] = [];
|
||||
const vjtToUse = visibleJoinTablesParam ?? visibleJoinTables;
|
||||
|
||||
for (let i = 0; i < queryFilter?.orderBys?.length; i++)
|
||||
{
|
||||
const criteria = sourceFilter.criteria[i];
|
||||
const {criteriaIsValid} = validateCriteria(criteria, null);
|
||||
if (criteriaIsValid)
|
||||
const fieldName = queryFilter.orderBys[i].fieldName;
|
||||
if(fieldName.indexOf(".") > -1)
|
||||
{
|
||||
if (criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK)
|
||||
const joinTableName = fieldName.replaceAll(/\..*/g, "");
|
||||
if(!vjtToUse.has(joinTableName))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// do this to avoid submitting an empty-string argument for blank/not-blank operators... //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, []));
|
||||
}
|
||||
else
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// else push a clone of the criteria - since it may get manipulated below (convertFilterPossibleValuesToIds) //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const [field] = FilterUtils.getField(tableMetaData, criteria.fieldName)
|
||||
filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, FilterUtils.cleanseCriteriaValueForQQQ(criteria.values, field)));
|
||||
const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
|
||||
handleChangeOneColumnVisibility(field, fieldTable, true);
|
||||
rs.push(fieldTable.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
filterForBackend.skip = pageNumber * rowsPerPage;
|
||||
filterForBackend.limit = rowsPerPage;
|
||||
|
||||
// FilterUtils.convertFilterPossibleValuesToIds(filterForBackend);
|
||||
// todo - expressions?
|
||||
// todo - utc
|
||||
return filterForBackend;
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** This is the method that actually executes a query to update the data in the table.
|
||||
*******************************************************************************/
|
||||
@ -729,6 +857,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
if (tableMetaData?.exposedJoins)
|
||||
{
|
||||
const visibleJoinTables = getVisibleJoinTables();
|
||||
const tablesToAdd = ensureOrderBysFromJoinTablesAreVisibleTables(queryFilter, visibleJoinTables);
|
||||
|
||||
tablesToAdd?.forEach(t => visibleJoinTables.add(t));
|
||||
|
||||
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables);
|
||||
}
|
||||
|
||||
@ -1062,15 +1194,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
doSetQueryFilter(queryFilter);
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** set the current view in state & local-storage - but do NOT update any
|
||||
** child-state data.
|
||||
**
|
||||
*******************************************************************************/
|
||||
const doSetView = (view: RecordQueryView): void =>
|
||||
const handleColumnHeaderClick = (params: GridColumnHeaderParams, event: MuiEvent, details: GridCallbackDetails): void =>
|
||||
{
|
||||
setView(view);
|
||||
setViewAsJson(JSON.stringify(view));
|
||||
localStorage.setItem(viewLocalStorageKey, JSON.stringify(view));
|
||||
event.defaultMuiPrevented = true;
|
||||
}
|
||||
|
||||
|
||||
@ -1112,6 +1242,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
{
|
||||
console.log(`Setting a new query filter: ${JSON.stringify(queryFilter)}`);
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// when we have a new filter, go back to page 0. //
|
||||
///////////////////////////////////////////////////
|
||||
setPageNumber(0);
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// in case there's no orderBys, set default here //
|
||||
///////////////////////////////////////////////////
|
||||
@ -1121,6 +1256,15 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
view.queryFilter = queryFilter;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// in case the order-by is from a join table, and that table doesn't have any visible fields, //
|
||||
// then activate the order-by field itself //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
ensureOrderBysFromJoinTablesAreVisibleTables(queryFilter);
|
||||
|
||||
//////////////////////////////
|
||||
// set the filter state var //
|
||||
//////////////////////////////
|
||||
setQueryFilter(queryFilter);
|
||||
|
||||
///////////////////////////////////////////////////////
|
||||
@ -1423,6 +1567,22 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
const buildTableDefaultView = (): RecordQueryView =>
|
||||
{
|
||||
const newDefaultView = new RecordQueryView();
|
||||
newDefaultView.queryFilter = new QQueryFilter([], [new QFilterOrderBy(tableMetaData.primaryKeyField, false)]);
|
||||
newDefaultView.queryColumns = QQueryColumns.buildDefaultForTable(tableMetaData);
|
||||
newDefaultView.viewIdentity = "empty";
|
||||
newDefaultView.rowsPerPage = defaultRowsPerPage;
|
||||
newDefaultView.quickFilterFieldNames = [];
|
||||
newDefaultView.mode = defaultMode;
|
||||
return newDefaultView;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** event handler for SavedViews component, to handle user selecting a view
|
||||
** (or clearing / selecting new)
|
||||
@ -1453,18 +1613,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
setCurrentSavedView(null);
|
||||
localStorage.removeItem(currentSavedViewLocalStorageKey);
|
||||
|
||||
/////////////////////////////////////////////////////
|
||||
// go back to a default query filter for the table //
|
||||
/////////////////////////////////////////////////////
|
||||
doSetQueryFilter(new QQueryFilter());
|
||||
|
||||
const queryColumns = QQueryColumns.buildDefaultForTable(tableMetaData);
|
||||
doSetQueryColumns(queryColumns)
|
||||
|
||||
/////////////////////////////////////////////////////
|
||||
// also reset the (user-added) quick-filter fields //
|
||||
/////////////////////////////////////////////////////
|
||||
doSetQuickFilterFieldNames([]);
|
||||
///////////////////////////////////////////////
|
||||
// activate a new default view for the table //
|
||||
///////////////////////////////////////////////
|
||||
activateView(buildTableDefaultView())
|
||||
}
|
||||
}
|
||||
|
||||
@ -1848,36 +2000,14 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
}
|
||||
};
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// props that get passed into all of the ExportMenuItem's below //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
const exportMenuItemRestProps =
|
||||
{
|
||||
tableMetaData: tableMetaData,
|
||||
totalRecords: totalRecords,
|
||||
columnsModel: columnsModel,
|
||||
columnVisibilityModel: columnVisibilityModel,
|
||||
queryFilter: prepQueryFilterForBackend(queryFilter)
|
||||
}
|
||||
|
||||
return (
|
||||
<GridToolbarContainer>
|
||||
<div>
|
||||
<Button id="refresh-button" onClick={() => updateTable("refresh button")} startIcon={<Icon>refresh</Icon>} sx={{pr: "1.25rem"}}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button id="refresh-button" onClick={() => updateTable("refresh button")} startIcon={<Icon>refresh</Icon>} sx={{pl: "1rem", pr: "0.5rem", minWidth: "unset"}}></Button>
|
||||
</div>
|
||||
{/* @ts-ignore */}
|
||||
<GridToolbarColumnsButton nonce={undefined} />
|
||||
<div style={{position: "relative"}}>
|
||||
{/* @ts-ignore */}
|
||||
<GridToolbarDensitySelector nonce={undefined} />
|
||||
{/* @ts-ignore */}
|
||||
<GridToolbarExportContainer nonce={undefined}>
|
||||
<ExportMenuItem format="csv" {...exportMenuItemRestProps} />
|
||||
<ExportMenuItem format="xlsx" {...exportMenuItemRestProps} />
|
||||
<ExportMenuItem format="json" {...exportMenuItemRestProps} />
|
||||
</GridToolbarExportContainer>
|
||||
</div>
|
||||
|
||||
<div style={{zIndex: 10}}>
|
||||
@ -1990,15 +2120,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** maybe something to do with how page header is in a context, but, it didn't
|
||||
** work to check pageLoadingState.isLoadingSlow inside an element that we put
|
||||
** in the page header, so, this works instead.
|
||||
*******************************************************************************/
|
||||
const setPageHeaderToLoadingSlow = (): void =>
|
||||
{
|
||||
setPageHeader("Loading...")
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
// use this to make changes to the queryFilter more likely to re-run the query //
|
||||
@ -2024,12 +2145,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
setPageState("loadingMetaData");
|
||||
pageLoadingState.setLoading();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// reset the page header to blank, and tell the pageLoadingState object that if it becomes slow, to show 'Loading' //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
setPageHeader("");
|
||||
pageLoadingState.setUponSlowCallback(setPageHeaderToLoadingSlow);
|
||||
|
||||
(async () =>
|
||||
{
|
||||
const metaData = await qController.loadMetaData();
|
||||
@ -2056,6 +2171,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
|
||||
(async () =>
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// now that we know the table - build a default view - initially, only used by SavedViews component, for showing if there's anything to be saved. //
|
||||
// but also used when user selects new-view from the view menu //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const newDefaultView = buildTableDefaultView();
|
||||
setTableDefaultView(newDefaultView);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// once we've loaded meta data, let's check the location to see if we should open a process //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@ -2212,7 +2334,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
(async () =>
|
||||
{
|
||||
const visibleJoinTables = getVisibleJoinTables();
|
||||
setPageHeader(getPageHeader(tableMetaData, visibleJoinTables, tableVariant));
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// todo - we used to be able to set "warnings" here (i think, like, for if a field got deleted from a table... //
|
||||
@ -2362,11 +2483,135 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
return (getLoadingScreen());
|
||||
}
|
||||
|
||||
let savedViewsComponent = null;
|
||||
if(metaData && metaData.processes.has("querySavedView"))
|
||||
{
|
||||
savedViewsComponent = (<SavedViews qController={qController} metaData={metaData} tableMetaData={tableMetaData} view={view} viewAsJson={viewAsJson} currentSavedView={currentSavedView} tableDefaultView={tableDefaultView} viewOnChangeCallback={handleSavedViewChange} loadingSavedView={loadingSavedView} />);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
const buildColumnMenu = () =>
|
||||
{
|
||||
//////////////////////////////////////////
|
||||
// default (no saved view, and "clean") //
|
||||
//////////////////////////////////////////
|
||||
let buttonBackground = "none";
|
||||
let buttonBorder = colors.grayLines.main;
|
||||
let buttonColor = colors.gray.main;
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// diff the current view with either the current saved one, if there's one active, else the table default //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const baseView = currentSavedView ? JSON.parse(currentSavedView.values.get("viewJson")) as RecordQueryView : tableDefaultView;
|
||||
const viewDiffs: string[] = [];
|
||||
SavedViewUtils.diffColumns(tableMetaData, baseView, view, viewDiffs)
|
||||
|
||||
if(viewDiffs.length == 0 && currentSavedView)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////
|
||||
// if 's a saved view, and it's "clean", show it in main style //
|
||||
/////////////////////////////////////////////////////////////////
|
||||
buttonBackground = accentColor;
|
||||
buttonBorder = accentColor;
|
||||
buttonColor = "#FFFFFF";
|
||||
}
|
||||
else if(viewDiffs.length > 0)
|
||||
{
|
||||
///////////////////////////////////////////////////
|
||||
// else if there are diffs, show alt/light style //
|
||||
///////////////////////////////////////////////////
|
||||
buttonBackground = accentColorLight;
|
||||
buttonBorder = accentColorLight;
|
||||
buttonColor = accentColor;
|
||||
}
|
||||
|
||||
const columnMenuButtonStyles = {
|
||||
borderRadius: "0.75rem",
|
||||
border: `1px solid ${buttonBorder}`,
|
||||
color: buttonColor,
|
||||
textTransform: "none",
|
||||
fontWeight: 500,
|
||||
fontSize: "0.875rem",
|
||||
p: "0.5rem",
|
||||
backgroundColor: buttonBackground,
|
||||
"&:focus:not(:hover)": {
|
||||
color: buttonColor,
|
||||
backgroundColor: buttonBackground,
|
||||
},
|
||||
"&:hover": {
|
||||
color: buttonColor,
|
||||
backgroundColor: buttonBackground,
|
||||
}
|
||||
}
|
||||
|
||||
return (<Box order="2">
|
||||
<FieldListMenu
|
||||
idPrefix="columns"
|
||||
tableMetaData={tableMetaData}
|
||||
showTableHeaderEvenIfNoExposedJoins={true}
|
||||
placeholder="Search Fields"
|
||||
buttonProps={{sx: columnMenuButtonStyles}}
|
||||
buttonChildren={<><Icon sx={{mr: "0.5rem"}}>view_week_outline</Icon> Columns ({view.queryColumns.getVisibleColumnCount()}) <Icon sx={{ml: "0.5rem"}}>keyboard_arrow_down</Icon></>}
|
||||
isModeToggle={true}
|
||||
toggleStates={view.queryColumns.getVisibilityToggleStates()}
|
||||
handleToggleField={handleChangeOneColumnVisibility}
|
||||
/>
|
||||
</Box>);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// these numbers help set the height of the grid (so page won't scroll) based on spcae above & below it //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
let spaceBelowGrid = 40;
|
||||
let spaceAboveGrid = 205;
|
||||
if(tableMetaData?.usesVariants)
|
||||
{
|
||||
spaceAboveGrid += 30;
|
||||
}
|
||||
|
||||
if(mode == "advanced")
|
||||
{
|
||||
spaceAboveGrid += 60;
|
||||
}
|
||||
|
||||
////////////////////////
|
||||
// main screen render //
|
||||
////////////////////////
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography textTransform="capitalize" variant="h3" noWrap>
|
||||
{pageLoadingState.isLoading() && ""}
|
||||
{pageLoadingState.isLoadingSlow() && "Loading..."}
|
||||
{pageLoadingState.isNotLoading() && getPageHeader(tableMetaData, visibleJoinTables, tableVariant)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<GotoRecordButton metaData={metaData} tableMetaData={tableMetaData} />
|
||||
<Box display="inline-block" width="150px">
|
||||
{
|
||||
tableMetaData &&
|
||||
<QueryScreenActionMenu
|
||||
metaData={metaData}
|
||||
tableMetaData={tableMetaData}
|
||||
tableProcesses={tableProcesses}
|
||||
bulkLoadClicked={bulkLoadClicked}
|
||||
bulkEditClicked={bulkEditClicked}
|
||||
bulkDeleteClicked={bulkDeleteClicked}
|
||||
processClicked={processClicked}
|
||||
/>
|
||||
}
|
||||
</Box>
|
||||
{
|
||||
table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission &&
|
||||
<QCreateNewButton tablePath={metaData?.getTablePathByName(tableName)} />
|
||||
}
|
||||
</Box>
|
||||
</Box>
|
||||
<div className="recordQuery">
|
||||
{/*
|
||||
// see code in ExportMenuItem that would use this
|
||||
@ -2411,34 +2656,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
</Collapse>
|
||||
) : null
|
||||
}
|
||||
<Box display="flex" justifyContent="flex-end" alignItems="flex-start" mb={2}>
|
||||
<Box display="flex" marginRight="auto">
|
||||
{
|
||||
metaData && metaData.processes.has("querySavedView") &&
|
||||
<SavedViews qController={qController} metaData={metaData} tableMetaData={tableMetaData} view={view} viewAsJson={viewAsJson} currentSavedView={currentSavedView} viewOnChangeCallback={handleSavedViewChange} loadingSavedView={loadingSavedView} />
|
||||
}
|
||||
</Box>
|
||||
|
||||
<GotoRecordButton metaData={metaData} tableMetaData={tableMetaData} />
|
||||
<Box display="flex" width="150px">
|
||||
{
|
||||
tableMetaData &&
|
||||
<QueryScreenActionMenu
|
||||
metaData={metaData}
|
||||
tableMetaData={tableMetaData}
|
||||
tableProcesses={tableProcesses}
|
||||
bulkLoadClicked={bulkLoadClicked}
|
||||
bulkEditClicked={bulkEditClicked}
|
||||
bulkDeleteClicked={bulkDeleteClicked}
|
||||
processClicked={processClicked}
|
||||
/>
|
||||
}
|
||||
</Box>
|
||||
{
|
||||
table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission &&
|
||||
<QCreateNewButton tablePath={metaData?.getTablePathByName(tableName)} />
|
||||
}
|
||||
</Box>
|
||||
|
||||
{
|
||||
metaData && tableMetaData &&
|
||||
@ -2454,6 +2671,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
gridApiRef={gridApiRef}
|
||||
mode={mode}
|
||||
setMode={doSetMode}
|
||||
savedViewsComponent={savedViewsComponent}
|
||||
columnMenuComponent={buildColumnMenu()}
|
||||
/>
|
||||
}
|
||||
|
||||
@ -2466,20 +2685,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
Pagination: CustomPagination,
|
||||
LoadingOverlay: CustomLoadingOverlay,
|
||||
ColumnMenu: CustomColumnMenu,
|
||||
ColumnsPanel: CustomColumnsPanel,
|
||||
FilterPanel: CustomFilterPanel,
|
||||
// @ts-ignore - this turns these off, whether TS likes it or not...
|
||||
ColumnsPanel: "", ColumnSortedDescendingIcon: "", ColumnSortedAscendingIcon: "", ColumnUnsortedIcon: "",
|
||||
ColumnHeaderFilterIconButton: CustomColumnHeaderFilterIconButton,
|
||||
}}
|
||||
componentsProps={{
|
||||
columnsPanel:
|
||||
{
|
||||
tableMetaData: tableMetaData,
|
||||
metaData: metaData,
|
||||
initialOpenedGroups: columnChooserOpenGroups,
|
||||
openGroupsChanger: setColumnChooserOpenGroups,
|
||||
initialFilterText: columnChooserFilterText,
|
||||
filterTextChanger: setColumnChooserFilterText
|
||||
},
|
||||
filterPanel:
|
||||
{
|
||||
tableMetaData: tableMetaData,
|
||||
@ -2522,12 +2733,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
onSelectionModelChange={handleSelectionChanged}
|
||||
onSortModelChange={handleSortChange}
|
||||
sortingOrder={["asc", "desc"]}
|
||||
sortModel={columnSortModel}
|
||||
onColumnHeaderClick={handleColumnHeaderClick}
|
||||
getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")}
|
||||
getRowId={(row) => row.__rowIndex}
|
||||
selectionModel={rowSelectionModel}
|
||||
hideFooterSelectedRowCount={true}
|
||||
sx={{border: 0, height: tableMetaData?.usesVariants ? "calc(100vh - 300px)" : "calc(100vh - 270px)"}}
|
||||
sx={{border: 0, height: `calc(100vh - ${spaceAboveGrid + spaceBelowGrid}px)`}}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
@ -2548,7 +2759,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
|
||||
{
|
||||
setTableVariantPromptOpen(false);
|
||||
setTableVariant(value);
|
||||
setPageHeader(getPageHeader(tableMetaData, visibleJoinTables, value));
|
||||
}} />
|
||||
}
|
||||
|
||||
|
@ -29,6 +29,13 @@
|
||||
min-height: calc(100vh - 450px) !important;
|
||||
}
|
||||
|
||||
/* we want to leave columns w/ the sortable attribute (so they have it in the column menu),
|
||||
but we've turned off the click-to-sort function, so remove hand cursor */
|
||||
.recordQuery .MuiDataGrid-columnHeader--sortable
|
||||
{
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
/* Disable red outlines on clicked cells */
|
||||
.MuiDataGrid-cell:focus,
|
||||
.MuiDataGrid-columnHeader:focus,
|
||||
@ -402,7 +409,8 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.custom-columns-panel .MuiSwitch-thumb
|
||||
.custom-columns-panel .MuiSwitch-thumb,
|
||||
.fieldListMenuBody .MuiSwitch-thumb
|
||||
{
|
||||
width: 15px !important;
|
||||
height: 15px !important;
|
||||
@ -428,7 +436,7 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
|
||||
{
|
||||
/* overwrite what the grid tries to do here, where it changes based on density... we always want the same. */
|
||||
/* transform: translate(274px, 305px) !important; */
|
||||
transform: translate(274px, 276px) !important;
|
||||
transform: translate(274px, 264px) !important;
|
||||
}
|
||||
|
||||
/* within the data-grid, the filter-panel's container has a max-height. mirror that, and set an overflow-y */
|
||||
|
@ -30,6 +30,7 @@ import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFil
|
||||
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
|
||||
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
|
||||
import {ThisOrLastPeriodExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/ThisOrLastPeriodExpression";
|
||||
import Box from "@mui/material/Box";
|
||||
import {GridSortModel} from "@mui/x-data-grid-pro";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
@ -539,9 +540,14 @@ class FilterUtils
|
||||
|
||||
if(styled)
|
||||
{
|
||||
return (<>
|
||||
<b>{fieldLabel}</b> {FilterUtils.operatorToHumanString(criteria, field)} <span style={{color: "#0062FF"}}>{valuesString}</span>
|
||||
</>);
|
||||
return (
|
||||
<Box display="inline" whiteSpace="nowrap" color="#FFFFFF" mb={"0.5rem"}>
|
||||
<Box display="inline" p="0.125rem" pl="0.5rem" sx={{background: "#0062FF"}} borderRadius="0.5rem 0 0 0.5rem">{fieldLabel} </Box>
|
||||
<Box display="inline" p="0.125rem" sx={{background: "#757575"}} borderRadius={valuesString ? "0" : "0 0.5rem 0.5rem 0"}> {FilterUtils.operatorToHumanString(criteria, field)} </Box>
|
||||
{valuesString && <Box display="inline" p="0.125rem" pr="0.5rem" sx={{background: "#009971"}} borderRadius="0 0.5rem 0.5rem 0"> {valuesString}</Box>}
|
||||
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
|
418
src/qqq/utils/qqq/SavedViewUtils.ts
Normal file
418
src/qqq/utils/qqq/SavedViewUtils.ts
Normal file
@ -0,0 +1,418 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2024. 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 {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
|
||||
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
|
||||
import {validateCriteria} from "qqq/components/query/FilterCriteriaRow";
|
||||
import QQueryColumns from "qqq/models/query/QQueryColumns";
|
||||
import RecordQueryView from "qqq/models/query/RecordQueryView";
|
||||
import FilterUtils from "qqq/utils/qqq/FilterUtils";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class for working with QQQ Saved Views
|
||||
**
|
||||
*******************************************************************************/
|
||||
export class SavedViewUtils
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static fieldNameToLabel = (tableMetaData: QTableMetaData, fieldName: string): string =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const [fieldMetaData, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
|
||||
if (fieldTable.name != tableMetaData.name)
|
||||
{
|
||||
return (fieldTable.label + ": " + fieldMetaData.label);
|
||||
}
|
||||
|
||||
return (fieldMetaData.label);
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
return (fieldName);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static diffFilters = (tableMetaData: QTableMetaData, savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void =>
|
||||
{
|
||||
try
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// inner helper function for reporting on the number of criteria for a field. //
|
||||
// e.g., will tell us "added criteria X" or "removed 2 criteria on Y" //
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
const diffCriteriaFunction = (base: QQueryFilter, compare: QQueryFilter, messagePrefix: string, isCheckForChanged = false) =>
|
||||
{
|
||||
const baseCriteriaMap: { [name: string]: QFilterCriteria[] } = {};
|
||||
base?.criteria?.forEach((criteria) =>
|
||||
{
|
||||
if (validateCriteria(criteria).criteriaIsValid)
|
||||
{
|
||||
if (!baseCriteriaMap[criteria.fieldName])
|
||||
{
|
||||
baseCriteriaMap[criteria.fieldName] = [];
|
||||
}
|
||||
baseCriteriaMap[criteria.fieldName].push(criteria);
|
||||
}
|
||||
});
|
||||
|
||||
const compareCriteriaMap: { [name: string]: QFilterCriteria[] } = {};
|
||||
compare?.criteria?.forEach((criteria) =>
|
||||
{
|
||||
if (validateCriteria(criteria).criteriaIsValid)
|
||||
{
|
||||
if (!compareCriteriaMap[criteria.fieldName])
|
||||
{
|
||||
compareCriteriaMap[criteria.fieldName] = [];
|
||||
}
|
||||
compareCriteriaMap[criteria.fieldName].push(criteria);
|
||||
}
|
||||
});
|
||||
|
||||
for (let fieldName of Object.keys(compareCriteriaMap))
|
||||
{
|
||||
const noBaseCriteria = baseCriteriaMap[fieldName]?.length ?? 0;
|
||||
const noCompareCriteria = compareCriteriaMap[fieldName]?.length ?? 0;
|
||||
|
||||
if (isCheckForChanged)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// first - if we're checking for changes to specific criteria (e.g., change id=5 to id<>5, //
|
||||
// or change id=5 to id=6, or change id=5 to id<>7) //
|
||||
// our "sweet spot" is if there's a single criteria on each side of the check //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (noBaseCriteria == 1 && noCompareCriteria == 1)
|
||||
{
|
||||
const baseCriteria = baseCriteriaMap[fieldName][0];
|
||||
const compareCriteria = compareCriteriaMap[fieldName][0];
|
||||
const baseValuesJSON = JSON.stringify(baseCriteria.values ?? []);
|
||||
const compareValuesJSON = JSON.stringify(compareCriteria.values ?? []);
|
||||
if (baseCriteria.operator != compareCriteria.operator || baseValuesJSON != compareValuesJSON)
|
||||
{
|
||||
viewDiffs.push(`Changed a filter from ${FilterUtils.criteriaToHumanString(tableMetaData, baseCriteria)} to ${FilterUtils.criteriaToHumanString(tableMetaData, compareCriteria)}`);
|
||||
}
|
||||
}
|
||||
else if (noBaseCriteria == noCompareCriteria)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// else - if the number of criteria on this field differs, that'll get caught in a non-isCheckForChanged call, so //
|
||||
// todo, i guess - this is kinda weak - but if there's the same number of criteria on a field, then just ... do a shitty JSON compare between them... //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const baseJSON = JSON.stringify(baseCriteriaMap[fieldName]);
|
||||
const compareJSON = JSON.stringify(compareCriteriaMap[fieldName]);
|
||||
if (baseJSON != compareJSON)
|
||||
{
|
||||
viewDiffs.push(`${messagePrefix} 1 or more filters on ${SavedViewUtils.fieldNameToLabel(tableMetaData, fieldName)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// else - we're not checking for changes to individual criteria - rather - we're just checking if criteria were added or removed. //
|
||||
// we'll do that by starting to see if the nubmer of criteria is different. //
|
||||
// and, only do it in only 1 direction, assuming we'll get called twice, with the base & compare sides flipped //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (noBaseCriteria < noCompareCriteria)
|
||||
{
|
||||
if (noBaseCriteria == 0 && noCompareCriteria == 1)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if the difference is 0 to 1 (1 to 0 when called in reverse), then we can report the full criteria that was added/removed //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
viewDiffs.push(`${messagePrefix} filter: ${FilterUtils.criteriaToHumanString(tableMetaData, compareCriteriaMap[fieldName][0])}`);
|
||||
}
|
||||
else
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// else, say 0 to 2, or 2 to 1 - just report on how many were changed... //
|
||||
// todo this isn't great, as you might have had, say, (A,B), and now you have (C) - but all we'll say is "removed 1"... //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const noDiffs = noCompareCriteria - noBaseCriteria;
|
||||
viewDiffs.push(`${messagePrefix} ${noDiffs} filters on ${SavedViewUtils.fieldNameToLabel(tableMetaData, fieldName)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
diffCriteriaFunction(savedView.queryFilter, activeView.queryFilter, "Added");
|
||||
diffCriteriaFunction(activeView.queryFilter, savedView.queryFilter, "Removed");
|
||||
diffCriteriaFunction(savedView.queryFilter, activeView.queryFilter, "Changed", true);
|
||||
|
||||
//////////////////////
|
||||
// boolean operator //
|
||||
//////////////////////
|
||||
if (savedView.queryFilter.booleanOperator != activeView.queryFilter.booleanOperator)
|
||||
{
|
||||
viewDiffs.push("Changed filter from 'And' to 'Or'");
|
||||
}
|
||||
|
||||
///////////////
|
||||
// order-bys //
|
||||
///////////////
|
||||
const savedOrderBys = savedView.queryFilter.orderBys;
|
||||
const activeOrderBys = activeView.queryFilter.orderBys;
|
||||
if (savedOrderBys.length != activeOrderBys.length)
|
||||
{
|
||||
viewDiffs.push("Changed sort");
|
||||
}
|
||||
else if (savedOrderBys.length > 0)
|
||||
{
|
||||
const toWord = ((b: boolean) => b ? "ascending" : "descending");
|
||||
if (savedOrderBys[0].fieldName != activeOrderBys[0].fieldName && savedOrderBys[0].isAscending != activeOrderBys[0].isAscending)
|
||||
{
|
||||
viewDiffs.push(`Changed sort from ${SavedViewUtils.fieldNameToLabel(tableMetaData, savedOrderBys[0].fieldName)} ${toWord(savedOrderBys[0].isAscending)} to ${SavedViewUtils.fieldNameToLabel(tableMetaData, activeOrderBys[0].fieldName)} ${toWord(activeOrderBys[0].isAscending)}`);
|
||||
}
|
||||
else if (savedOrderBys[0].fieldName != activeOrderBys[0].fieldName)
|
||||
{
|
||||
viewDiffs.push(`Changed sort field from ${SavedViewUtils.fieldNameToLabel(tableMetaData, savedOrderBys[0].fieldName)} to ${SavedViewUtils.fieldNameToLabel(tableMetaData, activeOrderBys[0].fieldName)}`);
|
||||
}
|
||||
else if (savedOrderBys[0].isAscending != activeOrderBys[0].isAscending)
|
||||
{
|
||||
viewDiffs.push(`Changed sort direction from ${toWord(savedOrderBys[0].isAscending)} to ${toWord(activeOrderBys[0].isAscending)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.log(`Error looking for differences in filters ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static diffColumns = (tableMetaData: QTableMetaData, savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!savedView.queryColumns || !savedView.queryColumns.columns || savedView.queryColumns.columns.length == 0)
|
||||
{
|
||||
viewDiffs.push("This view did not previously have columns saved with it, so the next time you save it they will be initialized.");
|
||||
return;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// nested function to help diff visible status of columns //
|
||||
////////////////////////////////////////////////////////////
|
||||
const diffVisibilityFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) =>
|
||||
{
|
||||
const baseColumnsMap: { [name: string]: boolean } = {};
|
||||
base.columns.forEach((column) =>
|
||||
{
|
||||
if (column.isVisible)
|
||||
{
|
||||
baseColumnsMap[column.name] = true;
|
||||
}
|
||||
});
|
||||
|
||||
const diffFields: string[] = [];
|
||||
for (let i = 0; i < compare.columns.length; i++)
|
||||
{
|
||||
const column = compare.columns[i];
|
||||
if (column.isVisible)
|
||||
{
|
||||
if (!baseColumnsMap[column.name])
|
||||
{
|
||||
diffFields.push(SavedViewUtils.fieldNameToLabel(tableMetaData, column.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (diffFields.length > 0)
|
||||
{
|
||||
if (diffFields.length > 5)
|
||||
{
|
||||
viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`);
|
||||
}
|
||||
else
|
||||
{
|
||||
viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// nested function to help diff pinned status of columns //
|
||||
///////////////////////////////////////////////////////////
|
||||
const diffPinsFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) =>
|
||||
{
|
||||
const baseColumnsMap: { [name: string]: string } = {};
|
||||
base.columns.forEach((column) => baseColumnsMap[column.name] = column.pinned);
|
||||
|
||||
const diffFields: string[] = [];
|
||||
for (let i = 0; i < compare.columns.length; i++)
|
||||
{
|
||||
const column = compare.columns[i];
|
||||
if (baseColumnsMap[column.name] != column.pinned)
|
||||
{
|
||||
diffFields.push(SavedViewUtils.fieldNameToLabel(tableMetaData, column.name));
|
||||
}
|
||||
}
|
||||
|
||||
if (diffFields.length > 0)
|
||||
{
|
||||
if (diffFields.length > 5)
|
||||
{
|
||||
viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`);
|
||||
}
|
||||
else
|
||||
{
|
||||
viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// nested function to help diff width of columns //
|
||||
///////////////////////////////////////////////////
|
||||
const diffWidthsFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) =>
|
||||
{
|
||||
const baseColumnsMap: { [name: string]: number } = {};
|
||||
base.columns.forEach((column) => baseColumnsMap[column.name] = column.width);
|
||||
|
||||
const diffFields: string[] = [];
|
||||
for (let i = 0; i < compare.columns.length; i++)
|
||||
{
|
||||
const column = compare.columns[i];
|
||||
if (baseColumnsMap[column.name] != column.width)
|
||||
{
|
||||
diffFields.push(SavedViewUtils.fieldNameToLabel(tableMetaData, column.name));
|
||||
}
|
||||
}
|
||||
|
||||
if (diffFields.length > 0)
|
||||
{
|
||||
if (diffFields.length > 5)
|
||||
{
|
||||
viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`);
|
||||
}
|
||||
else
|
||||
{
|
||||
viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
diffVisibilityFunction(savedView.queryColumns, activeView.queryColumns, "Turned on ");
|
||||
diffVisibilityFunction(activeView.queryColumns, savedView.queryColumns, "Turned off ");
|
||||
diffPinsFunction(savedView.queryColumns, activeView.queryColumns, "Changed pinned state for ");
|
||||
|
||||
if (savedView.queryColumns.columns.map(c => c.name).join(",") != activeView.queryColumns.columns.map(c => c.name).join(","))
|
||||
{
|
||||
viewDiffs.push("Changed the order of columns.");
|
||||
}
|
||||
|
||||
diffWidthsFunction(savedView.queryColumns, activeView.queryColumns, "Changed width for ");
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.log(`Error looking for differences in columns: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static diffQuickFilterFieldNames = (tableMetaData: QTableMetaData, savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const diffFunction = (base: string[], compare: string[], messagePrefix: string) =>
|
||||
{
|
||||
const baseFieldNameMap: { [name: string]: boolean } = {};
|
||||
base.forEach((name) => baseFieldNameMap[name] = true);
|
||||
const diffFields: string[] = [];
|
||||
for (let i = 0; i < compare.length; i++)
|
||||
{
|
||||
const name = compare[i];
|
||||
if (!baseFieldNameMap[name])
|
||||
{
|
||||
diffFields.push(SavedViewUtils.fieldNameToLabel(tableMetaData, name));
|
||||
}
|
||||
}
|
||||
|
||||
if (diffFields.length > 0)
|
||||
{
|
||||
viewDiffs.push(`${messagePrefix} basic filter${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`);
|
||||
}
|
||||
};
|
||||
|
||||
diffFunction(savedView.quickFilterFieldNames, activeView.quickFilterFieldNames, "Turned on");
|
||||
diffFunction(activeView.quickFilterFieldNames, savedView.quickFilterFieldNames, "Turned off");
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.log(`Error looking for differences in quick filter field names: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static diffViews = (tableMetaData: QTableMetaData, baseView: RecordQueryView, activeView: RecordQueryView): string[] =>
|
||||
{
|
||||
const viewDiffs: string[] = [];
|
||||
|
||||
SavedViewUtils.diffFilters(tableMetaData, baseView, activeView, viewDiffs);
|
||||
SavedViewUtils.diffColumns(tableMetaData, baseView, activeView, viewDiffs);
|
||||
SavedViewUtils.diffQuickFilterFieldNames(tableMetaData, baseView, activeView, viewDiffs);
|
||||
|
||||
if (baseView.mode != activeView.mode)
|
||||
{
|
||||
if (baseView.mode)
|
||||
{
|
||||
viewDiffs.push(`Mode changed from ${baseView.mode} to ${activeView.mode}`);
|
||||
}
|
||||
else
|
||||
{
|
||||
viewDiffs.push(`Mode set to ${activeView.mode}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (baseView.rowsPerPage != activeView.rowsPerPage)
|
||||
{
|
||||
if (baseView.rowsPerPage)
|
||||
{
|
||||
viewDiffs.push(`Rows per page changed from ${baseView.rowsPerPage} to ${activeView.rowsPerPage}`);
|
||||
}
|
||||
else
|
||||
{
|
||||
viewDiffs.push(`Rows per page set to ${activeView.rowsPerPage}`);
|
||||
}
|
||||
}
|
||||
return viewDiffs;
|
||||
};
|
||||
|
||||
}
|
Reference in New Issue
Block a user