CE-798 - primary working version of tsx for basic vs. advanced query (quick-filters in basic mode)

This commit is contained in:
2024-01-23 14:02:06 -06:00
parent c0221ae9fc
commit 78f764c4cd
11 changed files with 1460 additions and 628 deletions

View File

@ -37,7 +37,7 @@ interface QCreateNewButtonProps
export function QCreateNewButton({tablePath}: QCreateNewButtonProps): JSX.Element
{
return (
<Box ml={3} mr={2} width={standardWidth}>
<Box ml={3} mr={0} width={standardWidth}>
<Link to={`${tablePath}/create`}>
<MDButton variant="gradient" color="info" fullWidth startIcon={<Icon>add</Icon>}>
Create New

View File

@ -34,14 +34,16 @@ interface FieldAutoCompleteProps
tableMetaData: QTableMetaData;
handleFieldChange: (event: any, newValue: any, reason: string) => void;
defaultValue?: {field: QFieldMetaData, table: QTableMetaData, fieldName: string};
autoFocus?: boolean
hiddenFieldNames?: string[]
autoFocus?: boolean;
forceOpen?: boolean;
hiddenFieldNames?: string[];
}
FieldAutoComplete.defaultProps =
{
defaultValue: null,
autoFocus: false,
forceOpen: null,
hiddenFieldNames: []
};
@ -61,7 +63,7 @@ function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: a
}
}
export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, hiddenFieldNames}: FieldAutoCompleteProps): JSX.Element
export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames}: FieldAutoCompleteProps): JSX.Element
{
const fieldOptions: any[] = [];
makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames);
@ -124,6 +126,15 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi
return option.fieldName === value.fieldName;
}
///////////////////////////////////////////////////////////////////////////////////////////////////////
// seems like, if we always add the open attribute, then if its false or null, then the autocomplete //
// doesn't open at all... so, only add the attribute at all, if forceOpen is true //
///////////////////////////////////////////////////////////////////////////////////////////////////////
const alsoOpen: {[key: string]: any} = {}
if(forceOpen)
{
alsoOpen["open"] = forceOpen;
}
return (
<Autocomplete
@ -140,6 +151,7 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi
autoSelect={true}
autoHighlight={true}
slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}}
{...alsoOpen}
/>
);

View File

@ -0,0 +1,664 @@
/*
* 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 {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
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 {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {Badge, ToggleButton, ToggleButtonGroup, Typography} from "@mui/material";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
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 {GridFilterModel} from "@mui/x-data-grid-pro";
import {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro";
import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete";
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
import QuickFilter from "qqq/components/query/QuickFilter";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import TableUtils from "qqq/utils/qqq/TableUtils";
interface BasicAndAdvancedQueryControlsProps
{
metaData: QInstance;
tableMetaData: QTableMetaData;
queryFilter: QQueryFilter;
gridApiRef: React.MutableRefObject<GridApiPro>
setQueryFilter: (queryFilter: QQueryFilter) => void;
handleFilterChange: (filterModel: GridFilterModel, doSetQueryFilter?: boolean, isChangeFromDataGrid?: boolean) => void;
/////////////////////////////////////////////////////////////////////////////////////////////
// this prop is used as a way to recognize changes in the query filter internal structure, //
// since the queryFilter object (reference) doesn't get updated //
/////////////////////////////////////////////////////////////////////////////////////////////
queryFilterJSON: string;
mode: string;
setMode: (mode: string) => void;
}
let debounceTimeout: string | number | NodeJS.Timeout;
/*******************************************************************************
** Component to provide the basic & advanced query-filter controls for the
** RecordQuery screen.
**
** Done as a forwardRef, so RecordQuery can call some functions, e.g., when user
** does things on that screen, that we need to know about in here.
*******************************************************************************/
const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryControlsProps, ref) =>
{
const {metaData, tableMetaData, queryFilter, gridApiRef, setQueryFilter, handleFilterChange, queryFilterJSON, mode, setMode} = props
/////////////////////////////////////////////////////////
// get the quick-filter-field-names from local storage //
/////////////////////////////////////////////////////////
const QUICK_FILTER_FIELD_NAMES_LOCAL_STORAGE_KEY_ROOT = "qqq.quickFilterFieldNames";
const quickFilterFieldNamesLocalStorageKey = `${QUICK_FILTER_FIELD_NAMES_LOCAL_STORAGE_KEY_ROOT}.${tableMetaData.name}`;
let defaultQuickFilterFieldNames: Set<string> = new Set<string>();
if (localStorage.getItem(quickFilterFieldNamesLocalStorageKey))
{
defaultQuickFilterFieldNames = new Set<string>(JSON.parse(localStorage.getItem(quickFilterFieldNamesLocalStorageKey)));
}
/////////////////////
// state variables //
/////////////////////
const [quickFilterFieldNames, setQuickFilterFieldNames] = useState(defaultQuickFilterFieldNames);
const [addQuickFilterMenu, setAddQuickFilterMenu] = useState(null)
const [addQuickFilterOpenCounter, setAddQuickFilterOpenCounter] = useState(0);
const [showClearFiltersWarning, setShowClearFiltersWarning] = useState(false);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
//////////////////////////////////////////////////////////////////////////////////
// make some functions available to our parent - so it can tell us to do things //
//////////////////////////////////////////////////////////////////////////////////
useImperativeHandle(ref, () =>
{
return {
ensureAllFilterCriteriaAreActiveQuickFilters(currentFilter: QQueryFilter, reason: string)
{
ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, currentFilter, reason);
},
addField(fieldName: string)
{
addQuickFilterField({fieldName: fieldName}, "columnMenu");
}
}
});
/*******************************************************************************
** for a given field, set its default operator for quick-filter dropdowns.
*******************************************************************************/
function getDefaultOperatorForField(field: QFieldMetaData)
{
// todo - sometimes i want contains instead of equals on strings (client.name, for example...)
let defaultOperator = field?.possibleValueSourceName ? QCriteriaOperator.IN : QCriteriaOperator.EQUALS;
if (field?.type == QFieldType.DATE_TIME || field?.type == QFieldType.DATE)
{
defaultOperator = QCriteriaOperator.GREATER_THAN;
}
else if (field?.type == QFieldType.BOOLEAN)
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// for booleans, if we set a default, since none of them have values, then they are ALWAYS selected, which isn't what we want. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
defaultOperator = null;
}
return defaultOperator;
}
/*******************************************************************************
** Callback passed into the QuickFilter component, to update the criteria
** after user makes changes to it or to clear it out.
*******************************************************************************/
const updateQuickCriteria = (newCriteria: QFilterCriteria, needDebounce = false, doClearCriteria = false) =>
{
let found = false;
let foundIndex = null;
for (let i = 0; i < queryFilter?.criteria?.length; i++)
{
if(queryFilter.criteria[i].fieldName == newCriteria.fieldName)
{
queryFilter.criteria[i] = newCriteria;
found = true;
foundIndex = i;
break;
}
}
if(doClearCriteria)
{
if(found)
{
queryFilter.criteria.splice(foundIndex, 1);
setQueryFilter(queryFilter);
const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter);
handleFilterChange(gridFilterModel, false);
}
return;
}
if(!found)
{
if(!queryFilter.criteria)
{
queryFilter.criteria = [];
}
queryFilter.criteria.push(newCriteria);
found = true;
}
if(found)
{
clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(() =>
{
setQueryFilter(queryFilter);
const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter);
handleFilterChange(gridFilterModel, false);
}, needDebounce ? 500 : 1);
forceUpdate();
}
};
/*******************************************************************************
** Get the QFilterCriteriaWithId object to pass in to the QuickFilter component
** for a given field name.
*******************************************************************************/
const getQuickCriteriaParam = (fieldName: string): QFilterCriteriaWithId | null | "tooComplex" =>
{
const matches: QFilterCriteriaWithId[] = [];
for (let i = 0; i < queryFilter?.criteria?.length; i++)
{
if(queryFilter.criteria[i].fieldName == fieldName)
{
matches.push(queryFilter.criteria[i] as QFilterCriteriaWithId);
}
}
if(matches.length == 0)
{
return (null);
}
else if(matches.length == 1)
{
return (matches[0]);
}
else
{
return "tooComplex";
}
};
/*******************************************************************************
** set the quick-filter field names state variable and local-storage
*******************************************************************************/
const storeQuickFilterFieldNames = () =>
{
setQuickFilterFieldNames(new Set<string>([...quickFilterFieldNames.values()]));
localStorage.setItem(quickFilterFieldNamesLocalStorageKey, JSON.stringify([...quickFilterFieldNames.values()]));
}
/*******************************************************************************
** Event handler for QuickFilter component, to remove a quick filter field from
** the screen.
*******************************************************************************/
const handleRemoveQuickFilterField = (fieldName: string): void =>
{
if(quickFilterFieldNames.has(fieldName))
{
//////////////////////////////////////
// remove this field from the query //
//////////////////////////////////////
const criteria = new QFilterCriteria(fieldName, null, []);
updateQuickCriteria(criteria, false, true);
quickFilterFieldNames.delete(fieldName);
storeQuickFilterFieldNames();
}
};
/*******************************************************************************
** Event handler for button that opens the add-quick-filter menu
*******************************************************************************/
const openAddQuickFilterMenu = (event: any) =>
{
setAddQuickFilterMenu(event.currentTarget);
setAddQuickFilterOpenCounter(addQuickFilterOpenCounter + 1);
}
/*******************************************************************************
** Handle closing the add-quick-filter menu
*******************************************************************************/
const closeAddQuickFilterMenu = () =>
{
setAddQuickFilterMenu(null);
}
/*******************************************************************************
** Add a quick-filter field to the screen, from either the user selecting one,
** or from a new query being activated, etc.
*******************************************************************************/
const addQuickFilterField = (newValue: any, reason: "blur" | "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | "columnMenu" | string) =>
{
console.log(`Adding quick filter field as: ${JSON.stringify(newValue)}`);
if (reason == "blur")
{
//////////////////////////////////////////////////////////////////
// this keeps a click out of the menu from selecting the option //
//////////////////////////////////////////////////////////////////
return;
}
const fieldName = newValue ? newValue.fieldName : null;
if (fieldName)
{
if (!quickFilterFieldNames.has(fieldName))
{
/////////////////////////////////
// add the field if we need to //
/////////////////////////////////
quickFilterFieldNames.add(fieldName);
storeQuickFilterFieldNames();
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// only do this when user has added the field (e.g., not when adding it because of a selected view or filter-in-url) //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(reason != "modeToggleClicked" && reason != "defaultFilterLoaded" && reason != "savedFilterSelected")
{
setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5);
}
}
else if(reason == "columnMenu")
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if field was already on-screen, but user clicked an option from the columnMenu, then open the quick-filter field //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5);
}
closeAddQuickFilterMenu();
}
};
/*******************************************************************************
** event handler for the Filter Buidler button - e.g., opens the parent's grid's
** filter panel
*******************************************************************************/
const openFilterBuilder = (e: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>) =>
{
gridApiRef.current.showFilterPanel();
};
/*******************************************************************************
** event handler for the clear-filters modal
*******************************************************************************/
const handleClearFiltersAction = (event: React.KeyboardEvent<HTMLDivElement>, isYesButton: boolean = false) =>
{
if (isYesButton || event.key == "Enter")
{
setShowClearFiltersWarning(false);
handleFilterChange({items: []} as GridFilterModel);
}
};
/*******************************************************************************
** format the current query as a string for showing on-screen as a preview.
*******************************************************************************/
const queryToAdvancedString = () =>
{
if(queryFilter == null || !queryFilter.criteria)
{
return (<span></span>);
}
let counter = 0;
return (
<span>
{queryFilter.criteria.map((criteria, i) =>
{
if(criteria && criteria.fieldName && criteria.operator)
{
const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, criteria.fieldName);
const valuesString = FilterUtils.getValuesString(field, criteria);
counter++;
return (
<span key={i}>
{counter > 1 ? <span>{queryFilter.booleanOperator}&nbsp;</span> : <span/>}
<b>{field.label}</b> {criteria.operator} <span style={{color: "blue"}}>{valuesString}</span>&nbsp;
</span>
);
}
else
{
return (<span />);
}
})}
</span>
);
};
/*******************************************************************************
** event handler for toggling between modes - basic & advanced.
*******************************************************************************/
const modeToggleClicked = (newValue: string | null) =>
{
if (newValue)
{
if(newValue == "basic")
{
////////////////////////////////////////////////////////////////////////////////
// we're always allowed to go to advanced - //
// but if we're trying to go to basic, make sure the filter isn't too complex //
////////////////////////////////////////////////////////////////////////////////
const {canFilterWorkAsBasic} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter);
if (!canFilterWorkAsBasic)
{
console.log("Query cannot work as basic - so - not allowing toggle to basic.")
return;
}
////////////////////////////////////////////////////////////////////////////////////////////////
// when going to basic, make sure all fields in the current query are active as quick-filters //
////////////////////////////////////////////////////////////////////////////////////////////////
if (queryFilter && queryFilter.criteria)
{
ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "modeToggleClicked");
}
}
//////////////////////////////////////////////////////////////////////////////////////
// note - this is a callback to the parent - as it is responsible for this state... //
//////////////////////////////////////////////////////////////////////////////////////
setMode(newValue);
}
};
/*******************************************************************************
** make sure that all fields in the current query are on-screen as quick-filters
** (that is, if the query can be basic)
*******************************************************************************/
const ensureAllFilterCriteriaAreActiveQuickFilters = (tableMetaData: QTableMetaData, queryFilter: QQueryFilter, reason: "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | string) =>
{
if(!tableMetaData || !queryFilter)
{
return;
}
const {canFilterWorkAsBasic} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter);
if (!canFilterWorkAsBasic)
{
console.log("query is too complex for basic - so - switching to advanced");
modeToggleClicked("advanced");
forceUpdate();
return;
}
for (let i = 0; i < queryFilter?.criteria?.length; i++)
{
const criteria = queryFilter.criteria[i];
if (criteria && criteria.fieldName)
{
addQuickFilterField(criteria, reason);
}
}
}
//////////////////////////////////////////////////////////////////////////////
// if there aren't any quick-filters turned on, get defaults from the table //
// only run this block upon a first-render //
//////////////////////////////////////////////////////////////////////////////
const [firstRender, setFirstRender] = useState(true);
if(firstRender)
{
setFirstRender(false);
if (defaultQuickFilterFieldNames == null || defaultQuickFilterFieldNames.size == 0)
{
defaultQuickFilterFieldNames = new Set<string>();
//////////////////////////////////////////////////////////////////////////////////////////////////
// check if there's materialDashboard tableMetaData, and if it has defaultQuickFilterFieldNames //
//////////////////////////////////////////////////////////////////////////////////////////////////
const mdbMetaData = tableMetaData?.supplementalTableMetaData?.get("materialDashboard");
if (mdbMetaData)
{
if (mdbMetaData?.defaultQuickFilterFieldNames?.length)
{
for (let i = 0; i < mdbMetaData.defaultQuickFilterFieldNames.length; i++)
{
defaultQuickFilterFieldNames.add(mdbMetaData.defaultQuickFilterFieldNames[i]);
}
}
}
/////////////////////////////////////////////
// if still none, then look for T1 section //
/////////////////////////////////////////////
if (defaultQuickFilterFieldNames.size == 0)
{
if (tableMetaData.sections)
{
const t1Sections = tableMetaData.sections.filter((s: QTableSection) => s.tier == "T1");
if (t1Sections.length)
{
for (let i = 0; i < t1Sections.length; i++)
{
if (t1Sections[i].fieldNames)
{
for (let j = 0; j < t1Sections[i].fieldNames.length; j++)
{
defaultQuickFilterFieldNames.add(t1Sections[i].fieldNames[j]);
}
}
}
}
}
}
setQuickFilterFieldNames(defaultQuickFilterFieldNames);
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////
// this is being used as a version of like forcing that we get re-rendered if the query filter changes... //
////////////////////////////////////////////////////////////////////////////////////////////////////////////
const [lastIndex, setLastIndex] = useState(queryFilterJSON);
if(queryFilterJSON != lastIndex)
{
ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "defaultFilterLoaded");
setLastIndex(queryFilterJSON);
}
///////////////////////////////////////////////////
// set some status flags based on current filter //
///////////////////////////////////////////////////
const hasValidFilters = queryFilter && queryFilter.criteria && queryFilter.criteria.length > 0; // todo - should be better (e.g., see if operator & values are set)
const {canFilterWorkAsBasic, reasonsWhyItCannot} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter);
let reasonWhyBasicIsDisabled = null;
if(reasonsWhyItCannot && reasonsWhyItCannot.length > 0)
{
reasonWhyBasicIsDisabled = <>
Your current Filter cannot be managed using BASIC mode because:
<ul style={{marginLeft: "1rem"}}>
{reasonsWhyItCannot.map((reason, i) => <li key={i}>{reason}</li>)}
</ul>
</>
}
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}>
{
mode == "basic" &&
<Box width="100px" flexShrink={1} flexGrow={1}>
{
tableMetaData &&
[...quickFilterFieldNames.values()].map((fieldName) =>
{
const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
let defaultOperator = getDefaultOperatorForField(field);
return (
field && <QuickFilter
key={fieldName}
fullFieldName={fieldName}
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_circle_outline</Icon>} sx={{border: "1px solid gray", whiteSpace: "nowrap", minWidth: "120px"}}>
Add Field
</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={[...quickFilterFieldNames.values()]}
/>
</Box>
</Menu>
</>
}
</Box>
}
{
metaData && tableMetaData && mode == "advanced" &&
<>
<Tooltip enterDelay={500} title="Build an advanced Filter" placement="top">
<Button onClick={(e) => openFilterBuilder(e)} startIcon={<Badge badgeContent={queryFilter?.criteria?.length} 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 && (
<>
<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>
</>
)
}
</div>
<Box sx={{fontSize: "1rem"}} whiteSpace="nowrap" display="flex" ml={0.25} flexShrink={1} flexGrow={1} alignItems="center">
Current Filter:
{
<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}>
{queryToAdvancedString()}
</Box>
}
</Box>
</>
}
</Box>
<Box display="flex" alignItems="center">
{
metaData && tableMetaData &&
<Box px={1} display="flex" alignItems="center">
<Typography display="inline" sx={{fontSize: "1rem"}}>Mode:</Typography>
<Tooltip title={reasonWhyBasicIsDisabled}>
<ToggleButtonGroup
value={mode}
exclusive
onChange={(event, newValue) => modeToggleClicked(newValue)}
size="small"
sx={{pl: 0.5}}
>
<ToggleButton value="basic" disabled={!canFilterWorkAsBasic}>Basic</ToggleButton>
<ToggleButton value="advanced">Advanced</ToggleButton>
</ToggleButtonGroup>
</Tooltip>
</Box>
}
</Box>
</Box>
);
});
export default BasicAndAdvancedQueryControls;

View File

@ -0,0 +1,131 @@
/*
* 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 {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import MenuItem from "@mui/material/MenuItem";
import {GridColDef, GridExportMenuItemProps} from "@mui/x-data-grid-pro";
import React from "react";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
interface QExportMenuItemProps extends GridExportMenuItemProps<{}>
{
tableMetaData: QTableMetaData;
totalRecords: number
columnsModel: GridColDef[];
columnVisibilityModel: { [index: string]: boolean };
queryFilter: QQueryFilter;
format: string;
}
/*******************************************************************************
** Component to serve as an item in the Export menu
*******************************************************************************/
export default function ExportMenuItem(props: QExportMenuItemProps)
{
const {format, tableMetaData, totalRecords, columnsModel, columnVisibilityModel, queryFilter, hideMenu} = props;
return (
<MenuItem
disabled={totalRecords === 0}
onClick={() =>
{
///////////////////////////////////////////////////////////////////////////////
// build the list of visible fields. note, not doing them in-order (in case //
// the user did drag & drop), because column order model isn't right yet //
// so just doing them to match columns (which were pKey, then sorted) //
///////////////////////////////////////////////////////////////////////////////
const visibleFields: string[] = [];
columnsModel.forEach((gridColumn) =>
{
const fieldName = gridColumn.field;
if (columnVisibilityModel[fieldName] !== false)
{
visibleFields.push(fieldName);
}
});
//////////////////////////////////////
// construct the url for the export //
//////////////////////////////////////
const dateString = ValueUtils.formatDateTimeForFileName(new Date());
const filename = `${tableMetaData.label} Export ${dateString}.${format}`;
const url = `/data/${tableMetaData.name}/export/${filename}`;
const encodedFilterJSON = encodeURIComponent(JSON.stringify(queryFilter));
//////////////////////////////////////////////////////////////////////////////////////
// open a window (tab) with a little page that says the file is being generated. //
// then have that page load the url for the export. //
// If there's an error, it'll appear in that window. else, the file will download. //
//////////////////////////////////////////////////////////////////////////////////////
const exportWindow = window.open("", "_blank");
exportWindow.document.write(`<html lang="en">
<head>
<style>
* { font-family: "SF Pro Display","Roboto","Helvetica","Arial",sans-serif; }
</style>
<title>${filename}</title>
<script>
setTimeout(() =>
{
//////////////////////////////////////////////////////////////////////////////////////////////////
// need to encode and decode this value, so set it in the form here, instead of literally below //
//////////////////////////////////////////////////////////////////////////////////////////////////
document.getElementById("filter").value = decodeURIComponent("${encodedFilterJSON}");
document.getElementById("exportForm").submit();
}, 1);
</script>
</head>
<body>
Generating file <u>${filename}</u>${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}...
<form id="exportForm" method="post" action="${url}" >
<input type="hidden" name="fields" value="${visibleFields.join(",")}">
<input type="hidden" name="filter" id="filter">
</form>
</body>
</html>`);
/*
// todo - probably better - generate the report in an iframe...
// only open question is, giving user immediate feedback, and knowing when the stream has started and/or stopped
// maybe a busy-loop that would check iframe's url (e.g., after posting should change, maybe?)
const iframe = document.getElementById("exportIFrame");
const form = iframe.querySelector("form");
form.action = url;
form.target = "exportIFrame";
(iframe.querySelector("#authorizationInput") as HTMLInputElement).value = qController.getAuthorizationHeaderValue();
form.submit();
*/
///////////////////////////////////////////
// Hide the export menu after the export //
///////////////////////////////////////////
hideMenu?.();
}}
>
Export
{` ${format.toUpperCase()}`}
</MenuItem>
);
}

View File

@ -20,6 +20,7 @@
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
@ -35,9 +36,10 @@ import React, {SyntheticEvent, useState} from "react";
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 FilterUtils from "qqq/utils/qqq/FilterUtils";
import TableUtils from "qqq/utils/qqq/TableUtils";
type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex";
export type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex";
interface QuickFilterProps
{
@ -47,27 +49,64 @@ interface QuickFilterProps
criteriaParam: CriteriaParamType;
updateCriteria: (newCriteria: QFilterCriteria, needDebounce: boolean, doRemoveCriteria: boolean) => void;
defaultOperator?: QCriteriaOperator;
toggleQuickFilterField?: (fieldName: string) => void;
handleRemoveQuickFilterField?: (fieldName: string) => void;
}
QuickFilter.defaultProps =
{
defaultOperator: QCriteriaOperator.EQUALS,
handleRemoveQuickFilterField: null
};
let seedId = new Date().getTime() % 173237;
/*******************************************************************************
** Test if a CriteriaParamType represents an actual query criteria - or, if it's
** null or the "tooComplex" placeholder.
*******************************************************************************/
const criteriaParamIsCriteria = (param: CriteriaParamType): boolean =>
{
return (param != null && param != "tooComplex");
};
QuickFilter.defaultProps =
/*******************************************************************************
** Test of an OperatorOption equals a query Criteria - that is - that the
** operators within them are equal - AND - if the OperatorOption has implicit
** values (e.g., the booleans), then those options equal the criteria's options.
*******************************************************************************/
const doesOperatorOptionEqualCriteria = (operatorOption: OperatorOption, criteria: QFilterCriteriaWithId): boolean =>
{
if(operatorOption.value == criteria.operator)
{
defaultOperator: QCriteriaOperator.EQUALS,
toggleQuickFilterField: null
};
if(operatorOption.implicitValues)
{
if(JSON.stringify(operatorOption.implicitValues) == JSON.stringify(criteria.values))
{
return (true);
}
else
{
return (false);
}
}
let seedId = new Date().getTime() % 173237;
return (true);
}
return (false);
}
/*******************************************************************************
** Get the object to use as the selected OperatorOption (e.g., value for that
** autocomplete), given an array of options, the query's active criteria in this
** field, and the default operator to use for this field
*******************************************************************************/
const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator): OperatorOption =>
{
if(criteria)
{
const filteredOptions = operatorOptions.filter(o => o.value == criteria.operator);
const filteredOptions = operatorOptions.filter(o => doesOperatorOptionEqualCriteria(o, criteria));
if(filteredOptions.length > 0)
{
return (filteredOptions[0]);
@ -83,10 +122,14 @@ const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: Q
return (null);
}
export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData, criteriaParam, updateCriteria, defaultOperator, toggleQuickFilterField}: QuickFilterProps): JSX.Element
/*******************************************************************************
** Component to render a QuickFilter - that is - a button, with a Menu under it,
** with Operator and Value controls.
*******************************************************************************/
export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData, criteriaParam, updateCriteria, defaultOperator, handleRemoveQuickFilterField}: QuickFilterProps): JSX.Element
{
const operatorOptions = fieldMetaData ? getOperatorOptions(tableMetaData, fullFieldName) : [];
const tableForField = tableMetaData; // todo!! const [_, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fullFieldName);
const [_, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fullFieldName);
const [isOpen, setIsOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState(null);
@ -97,17 +140,9 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator));
const [operatorInputValue, setOperatorInputValue] = useState(operatorSelectedValue?.label);
const maybeNewOperatorSelectedValue = getOperatorSelectedValue(operatorOptions, criteria, defaultOperator);
if(JSON.stringify(maybeNewOperatorSelectedValue) !== JSON.stringify(operatorSelectedValue))
{
setOperatorSelectedValue(maybeNewOperatorSelectedValue)
setOperatorInputValue(maybeNewOperatorSelectedValue.label)
}
const [startIconName, setStartIconName] = useState("filter_alt");
if(!fieldMetaData)
{
return (null);
}
const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue);
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
// handle a change to the criteria from outside this component (e.g., the prop isn't the same as the state) //
@ -117,16 +152,20 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
const newCriteria = criteriaParam as QFilterCriteriaWithId;
setCriteria(newCriteria);
const operatorOption = operatorOptions.filter(o => o.value == newCriteria.operator)[0];
console.log(`B: setOperatorSelectedValue [${JSON.stringify(operatorOption)}]`);
setOperatorSelectedValue(operatorOption);
setOperatorInputValue(operatorOption.label);
}
/*******************************************************************************
** Test if we need to construct a new criteria object
*******************************************************************************/
const criteriaNeedsReset = (): boolean =>
{
if(criteria != null && criteriaParam == null)
{
const defaultOperatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0];
if(criteria.operator !== defaultOperatorOption.value || JSON.stringify(criteria.values) !== JSON.stringify(getDefaultCriteriaValue()))
if(criteria.operator !== defaultOperatorOption?.value || JSON.stringify(criteria.values) !== JSON.stringify(getDefaultCriteriaValue()))
{
return (true);
}
@ -135,44 +174,50 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
return (false);
}
/*******************************************************************************
** Construct a new criteria object - resetting the values tied to the oprator
** autocomplete at the same time.
*******************************************************************************/
const makeNewCriteria = (): QFilterCriteria =>
{
const operatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0];
const criteria = new QFilterCriteriaWithId(fullFieldName, operatorOption.value, getDefaultCriteriaValue());
const criteria = new QFilterCriteriaWithId(fullFieldName, operatorOption?.value, getDefaultCriteriaValue());
criteria.id = id;
console.log(`C: setOperatorSelectedValue [${JSON.stringify(operatorOption)}]`);
setOperatorSelectedValue(operatorOption);
setOperatorInputValue(operatorOption.label);
setOperatorInputValue(operatorOption?.label);
setCriteria(criteria);
return(criteria);
}
if (criteria == null || criteriaNeedsReset())
{
makeNewCriteria();
}
const toggleOpen = (event: any) =>
/*******************************************************************************
** event handler to open the menu in response to the button being clicked.
*******************************************************************************/
const handleOpenMenu = (event: any) =>
{
setIsOpen(!isOpen);
setAnchorEl(event.currentTarget);
};
/*******************************************************************************
** handler for the Menu when being closed
*******************************************************************************/
const closeMenu = () =>
{
setIsOpen(false);
setAnchorEl(null);
};
/////////////////////////////////////////////
// event handler for operator Autocomplete //
// todo - too dupe?
/////////////////////////////////////////////
/*******************************************************************************
** event handler for operator Autocomplete having its value changed
*******************************************************************************/
const handleOperatorChange = (event: any, newValue: any, reason: string) =>
{
criteria.operator = newValue ? newValue.value : null;
if (newValue)
{
console.log(`D: setOperatorSelectedValue [${JSON.stringify(newValue)}]`);
setOperatorSelectedValue(newValue);
setOperatorInputValue(newValue.label);
@ -183,6 +228,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
}
else
{
console.log("E: setOperatorSelectedValue [null]");
setOperatorSelectedValue(null);
setOperatorInputValue("");
}
@ -190,15 +236,18 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
updateCriteria(criteria, false, false);
};
/*******************************************************************************
** implementation of isOptionEqualToValue for Autocomplete - compares both the
** value (e.g., what operator it is) and the implicitValues within the option
*******************************************************************************/
function isOperatorOptionEqual(option: OperatorOption, value: OperatorOption)
{
return (option?.value == value?.value && JSON.stringify(option?.implicitValues) == JSON.stringify(value?.implicitValues));
}
//////////////////////////////////////////////////
// event handler for value field (of all types) //
// todo - too dupe!
//////////////////////////////////////////////////
/*******************************************************************************
** event handler for the value field (of all types), when it changes
*******************************************************************************/
const handleValueChange = (event: React.ChangeEvent | SyntheticEvent, valueIndex: number | "all" = 0, newValue?: any) =>
{
// @ts-ignore
@ -221,48 +270,17 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
updateCriteria(criteria, true, false);
};
/*******************************************************************************
** a noop event handler, e.g., for a too-complex
*******************************************************************************/
const noop = () =>
{
};
const getValuesString = (): string =>
{
let valuesString = "";
if (criteria.values && criteria.values.length)
{
let labels = [] as string[];
let maxLoops = criteria.values.length;
if (maxLoops > 5)
{
maxLoops = 3;
}
for (let i = 0; i < maxLoops; i++)
{
if (criteria.values[i] && criteria.values[i].label)
{
labels.push(criteria.values[i].label);
}
else
{
labels.push(criteria.values[i]);
}
}
if (maxLoops < criteria.values.length)
{
labels.push(" and " + (criteria.values.length - maxLoops) + " other values.");
}
valuesString = (labels.join(", "));
}
return valuesString;
}
const [startIconName, setStartIconName] = useState("filter_alt");
const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue);
/*******************************************************************************
** event handler that responds to 'x' button that removes the criteria from the
** quick-filter, resetting it to a new filter.
*******************************************************************************/
const resetCriteria = (e: React.MouseEvent<HTMLSpanElement>) =>
{
if(criteriaIsValid)
@ -274,6 +292,10 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
}
}
/*******************************************************************************
** event handler for mouse-over on the filter icon - that changes to an 'x'
** if there's a valid criteria in the quick-filter
*******************************************************************************/
const startIconMouseOver = () =>
{
if(criteriaIsValid)
@ -281,11 +303,56 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
setStartIconName("clear");
}
}
/*******************************************************************************
** event handler for mouse-out on the filter icon - always resets it.
*******************************************************************************/
const startIconMouseOut = () =>
{
setStartIconName("filter_alt");
}
/*******************************************************************************
** event handler for clicking the (x) icon that turns off this quick filter field.
** hands off control to the function that was passed in (e.g., from RecordQuery).
*******************************************************************************/
const handleTurningOffQuickFilterField = () =>
{
closeMenu()
handleRemoveQuickFilterField(criteria?.fieldName);
}
////////////////////////////////////////////////////////////////////////////////////
// if no field was input (e.g., record-query is still loading), return null early //
////////////////////////////////////////////////////////////////////////////////////
if(!fieldMetaData)
{
return (null);
}
//////////////////////////////////////////////////////////////////////////////////////////
// if there should be a selected value in the operator autocomplete, and it's different //
// from the last selected one, then set the state vars that control that autocomplete //
//////////////////////////////////////////////////////////////////////////////////////////
const maybeNewOperatorSelectedValue = getOperatorSelectedValue(operatorOptions, criteria, defaultOperator);
if(JSON.stringify(maybeNewOperatorSelectedValue) !== JSON.stringify(operatorSelectedValue))
{
console.log(`A: setOperatorSelectedValue [${JSON.stringify(maybeNewOperatorSelectedValue)}]`);
setOperatorSelectedValue(maybeNewOperatorSelectedValue)
setOperatorInputValue(maybeNewOperatorSelectedValue?.label)
}
/////////////////////////////////////////////////////////////////////////////////////
// if there wasn't a criteria, or we need to reset it (make a new one), then do so //
/////////////////////////////////////////////////////////////////////////////////////
if (criteria == null || criteriaNeedsReset())
{
makeNewCriteria();
}
/////////////////////////
// build up the button //
/////////////////////////
const tooComplex = criteriaParam == "tooComplex";
const tooltipEnterDelay = 500;
let startIcon = <Badge badgeContent={criteriaIsValid && !tooComplex ? 1 : 0} color="warning" variant="dot" onMouseOver={startIconMouseOver} onMouseOut={startIconMouseOut} onClick={resetCriteria}><Icon>{startIconName}</Icon></Badge>
@ -298,22 +365,29 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
if (criteriaIsValid)
{
buttonContent = (
<Tooltip title={`${operatorSelectedValue.label} ${getValuesString()}`} enterDelay={tooltipEnterDelay}>
<Tooltip title={`${operatorSelectedValue.label} ${FilterUtils.getValuesString(fieldMetaData, criteria)}`} enterDelay={tooltipEnterDelay}>
{buttonContent}
</Tooltip>
);
}
let button = fieldMetaData && <Button
id={`quickFilter.${fullFieldName}`}
sx={{mr: "0.25rem", px: "1rem", border: isOpen ? "1px solid gray" : "1px solid transparent"}}
startIcon={startIcon}
onClick={tooComplex ? noop : toggleOpen}
onClick={tooComplex ? noop : handleOpenMenu}
disabled={tooComplex}
>{buttonContent}</Button>;
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if the criteria on this field is the "tooComplex" sentinel, then wrap the button in a tooltip stating such, and return early. //
// note this was part of original design on this widget, but later deprecated... //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (tooComplex)
{
// wrap button in span, so disabled button doesn't cause disabled tooltip
////////////////////////////////////////////////////////////////////////////
// wrap button in span, so disabled button doesn't cause disabled tooltip //
////////////////////////////////////////////////////////////////////////////
return (
<Tooltip title={`Your current filter is too complex to do a Quick Filter on ${fieldMetaData.label}. Use the Filter button to edit.`} enterDelay={tooltipEnterDelay} slotProps={{popper: {sx: {top: "-0.75rem!important"}}}}>
<span>{button}</span>
@ -321,12 +395,9 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
);
}
const doToggle = () =>
{
closeMenu()
toggleQuickFilterField(criteria?.fieldName);
}
//////////////////////////////
// return the button & menu //
//////////////////////////////
const widthAndMaxWidth = 250
return (
<>
@ -334,9 +405,9 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
{
isOpen && <Menu open={Boolean(anchorEl)} anchorEl={anchorEl} onClose={closeMenu} sx={{overflow: "visible"}}>
{
toggleQuickFilterField &&
handleRemoveQuickFilterField &&
<Tooltip title={"Remove this field from Quick Filters"} placement="right">
<IconButton size="small" sx={{position: "absolute", top: "-8px", right: "-8px", zIndex: 1}} onClick={doToggle}><Icon color="secondary">highlight_off</Icon></IconButton>
<IconButton size="small" sx={{position: "absolute", top: "-8px", right: "-8px", zIndex: 1}} onClick={handleTurningOffQuickFilterField}><Icon color="secondary">highlight_off</Icon></IconButton>
</Tooltip>
}
<Box display="inline-block" width={widthAndMaxWidth} maxWidth={widthAndMaxWidth} className="operatorColumn">

View File

@ -0,0 +1,74 @@
/*
* 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 Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import TextField from "@mui/material/TextField";
import React, {useState} from "react";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
/*******************************************************************************
** Component that is the dialog for the user to enter the selection-subset
*******************************************************************************/
export default function SelectionSubsetDialog(props: { isOpen: boolean; initialValue: number; closeHandler: (value?: number) => void })
{
const [value, setValue] = useState(props.initialValue);
const handleChange = (newValue: string) =>
{
setValue(parseInt(newValue));
};
const keyPressed = (e: React.KeyboardEvent<HTMLDivElement>) =>
{
if (e.key == "Enter" && value)
{
props.closeHandler(value);
}
};
return (
<Dialog open={props.isOpen} onClose={() => props.closeHandler()} onKeyPress={(e) => keyPressed(e)}>
<DialogTitle>Subset of the Query Result</DialogTitle>
<DialogContent>
<DialogContentText>How many records do you want to select?</DialogContentText>
<TextField
autoFocus
name="selection-subset-size"
inputProps={{width: "100%", type: "number", min: 1}}
onChange={(e) => handleChange(e.target.value)}
value={value}
sx={{width: "100%"}}
onFocus={event => event.target.select()}
/>
</DialogContent>
<DialogActions>
<QCancelButton disabled={false} onClickHandler={() => props.closeHandler()} />
<QSaveButton label="OK" iconName="check" disabled={value == undefined || isNaN(value)} onClickHandler={() => props.closeHandler(value)} />
</DialogActions>
</Dialog>
);
}

View File

@ -0,0 +1,122 @@
/*
* 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 {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant";
import Autocomplete from "@mui/material/Autocomplete";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import TextField from "@mui/material/TextField";
import React, {useEffect, useState} from "react";
import {TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT} from "qqq/pages/records/query/RecordQuery";
import Client from "qqq/utils/qqq/Client";
const qController = Client.getInstance();
/*******************************************************************************
** Component that is the dialog for the user to select a variant on tables with variant backends //
*******************************************************************************/
export default function TableVariantDialog(props: { isOpen: boolean; table: QTableMetaData; closeHandler: (value?: QTableVariant) => void })
{
const [value, setValue] = useState(null);
const [dropDownOpen, setDropDownOpen] = useState(false);
const [variants, setVariants] = useState(null);
const handleVariantChange = (event: React.SyntheticEvent, value: any | any[], reason: string, details?: string) =>
{
const tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${props.table.name}`;
if (value != null)
{
localStorage.setItem(tableVariantLocalStorageKey, JSON.stringify(value));
}
else
{
localStorage.removeItem(tableVariantLocalStorageKey);
}
props.closeHandler(value);
};
const keyPressed = (e: React.KeyboardEvent<HTMLDivElement>) =>
{
if (e.key == "Enter" && value)
{
props.closeHandler(value);
}
};
useEffect(() =>
{
console.log("queryVariants");
try
{
(async () =>
{
const variants = await qController.tableVariants(props.table.name);
console.log(JSON.stringify(variants));
setVariants(variants);
})();
}
catch (e)
{
console.log(e);
}
}, []);
return variants && (
<Dialog open={props.isOpen} onKeyPress={(e) => keyPressed(e)}>
<DialogTitle>{props.table.variantTableLabel}</DialogTitle>
<DialogContent>
<DialogContentText>Select the {props.table.variantTableLabel} to be used on this table:</DialogContentText>
<Autocomplete
id="tableVariantId"
sx={{width: "400px", marginTop: "10px"}}
open={dropDownOpen}
size="small"
onOpen={() =>
{
setDropDownOpen(true);
}}
onClose={() =>
{
setDropDownOpen(false);
}}
// @ts-ignore
onChange={handleVariantChange}
isOptionEqualToValue={(option, value) => option.id === value.id}
options={variants}
renderInput={(params) => <TextField {...params} label={props.table.variantTableLabel} />}
getOptionLabel={(option) =>
{
if (typeof option == "object")
{
return (option as QTableVariant).name;
}
return option;
}}
/>
</DialogContent>
</Dialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -424,6 +424,12 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
top: -60px !important;
}
.MuiDataGrid-panel:has(.customFilterPanel)
{
/* overwrite what the grid tries to do here, where it changes based on density... we always want the same. */
transform: translate(274px, 305px) !important;
}
/* within the data-grid, the filter-panel's container has a max-height. mirror that, and set an overflow-y */
.MuiDataGrid-panel .customFilterPanel
{

View File

@ -358,9 +358,9 @@ class FilterUtils
////////////////////////////////////////////////////////////////////////////////////////////////
if (fieldType === QFieldType.DATE_TIME)
{
for(let i = 0; i<values.length; i++)
for (let i = 0; i < values.length; i++)
{
if(!values[i].type)
if (!values[i].type)
{
values[i] = ValueUtils.formatDateTimeValueForForm(values[i]);
}
@ -402,7 +402,7 @@ class FilterUtils
if (field == null)
{
console.log("Couldn't find field for filter: " + criteria.fieldName);
warningParts.push("Your filter contained an unrecognized field name: " + criteria.fieldName)
warningParts.push("Your filter contained an unrecognized field name: " + criteria.fieldName);
continue;
}
@ -432,11 +432,11 @@ class FilterUtils
//////////////////////////////////////////////////////////////////////////
// replace objects that look like expressions with expression instances //
//////////////////////////////////////////////////////////////////////////
if(values && values.length)
if (values && values.length)
{
for (let i = 0; i < values.length; i++)
{
const expression = this.gridCriteriaValueToExpression(values[i])
const expression = this.gridCriteriaValueToExpression(values[i]);
if (expression)
{
values[i] = expression;
@ -508,16 +508,16 @@ class FilterUtils
// if any values in the items are objects, but should be expression instances, //
// then convert & replace them. //
/////////////////////////////////////////////////////////////////////////////////
if(defaultFilter && defaultFilter.items && defaultFilter.items.length)
if (defaultFilter && defaultFilter.items && defaultFilter.items.length)
{
defaultFilter.items.forEach((item) =>
{
if(item.value && item.value.length)
if (item.value && item.value.length)
{
for (let i = 0; i < item.value.length; i++)
{
const expression = this.gridCriteriaValueToExpression(item.value[i])
if(expression)
const expression = this.gridCriteriaValueToExpression(item.value[i]);
if (expression)
{
item.value[i] = expression;
}
@ -525,8 +525,8 @@ class FilterUtils
}
else
{
const expression = this.gridCriteriaValueToExpression(item.value)
if(expression)
const expression = this.gridCriteriaValueToExpression(item.value);
if (expression)
{
item.value = expression;
}
@ -641,7 +641,7 @@ class FilterUtils
let incomplete = false;
if (item.operatorValue === "between" || item.operatorValue === "notBetween")
{
if(!item.value || !item.value.length || item.value.length < 2 || this.isUnset(item.value[0]) || this.isUnset(item.value[1]))
if (!item.value || !item.value.length || item.value.length < 2 || this.isUnset(item.value[0]) || this.isUnset(item.value[1]))
{
incomplete = true;
}
@ -747,6 +747,103 @@ class FilterUtils
return (filter);
}
/*******************************************************************************
**
*******************************************************************************/
public static canFilterWorkAsBasic(tableMetaData: QTableMetaData, filter: QQueryFilter): { canFilterWorkAsBasic: boolean; reasonsWhyItCannot?: string[] }
{
const reasonsWhyItCannot: string[] = [];
if(filter == null)
{
return ({canFilterWorkAsBasic: true});
}
if(filter.booleanOperator == "OR")
{
reasonsWhyItCannot.push("Filter uses the 'OR' operator.")
}
if(filter.criteria)
{
const usedFields: {[name: string]: boolean} = {};
const warnedFields: {[name: string]: boolean} = {};
for (let i = 0; i < filter.criteria.length; i++)
{
const criteriaName = filter.criteria[i].fieldName;
if(!criteriaName)
{
continue;
}
if(usedFields[criteriaName])
{
if(!warnedFields[criteriaName])
{
const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, criteriaName);
let fieldLabel = field.label;
if(tableForField.name != tableMetaData.name)
{
let fieldLabel = `${tableForField.label}: ${field.label}`;
}
reasonsWhyItCannot.push(`Filter contains more than 1 condition for the field: ${fieldLabel}`);
warnedFields[criteriaName] = true;
}
}
usedFields[criteriaName] = true;
}
}
if(reasonsWhyItCannot.length == 0)
{
return ({canFilterWorkAsBasic: true});
}
else
{
return ({canFilterWorkAsBasic: false, reasonsWhyItCannot: reasonsWhyItCannot});
}
}
/*******************************************************************************
** get the values associated with a criteria as a string, e.g., for showing
** in a tooltip.
*******************************************************************************/
public static getValuesString(fieldMetaData: QFieldMetaData, criteria: QFilterCriteria, maxValuesToShow: number = 3): string
{
let valuesString = "";
if (criteria.values && criteria.values.length && fieldMetaData.type !== QFieldType.BOOLEAN)
{
let labels = [] as string[];
let maxLoops = criteria.values.length;
if (maxLoops > (maxValuesToShow + 2))
{
maxLoops = maxValuesToShow;
}
for (let i = 0; i < maxLoops; i++)
{
if (criteria.values[i] && criteria.values[i].label)
{
labels.push(criteria.values[i].label);
}
else
{
labels.push(criteria.values[i]);
}
}
if (maxLoops < criteria.values.length)
{
labels.push(" and " + (criteria.values.length - maxLoops) + " other values.");
}
valuesString = (labels.join(", "));
}
return valuesString;
}
}
export default FilterUtils;

View File

@ -462,6 +462,19 @@ class ValueUtils
return (String(param).replaceAll(/"/g, "\"\""));
}
/*******************************************************************************
**
*******************************************************************************/
public static safeToLocaleString(n: Number): string
{
if (n != null && n != undefined)
{
return (n.toLocaleString());
}
return ("");
}
}
////////////////////////////////////////////////////////////////////////////////////////////////