SPRINT-13: checkpoint on front end updates including, bulk add filters, bug when empty value on filter is saved, density storage

This commit is contained in:
Tim Chamberlain
2022-10-13 14:14:26 -05:00
parent 0d16b82ad0
commit e127098b36
7 changed files with 1593 additions and 865 deletions

1599
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -30,12 +30,14 @@
"@testing-library/user-event": "13.5.0",
"@types/jest": "27.4.0",
"@types/node": "16.11.21",
"@types/prop-types": "^15.7.5",
"@types/react": "17.0.38",
"@types/react-dom": "17.0.11",
"@types/react-router-hash-link": "2.4.5",
"chart.js": "3.4.1",
"chroma-js": "2.4.2",
"datejs": "1.0.0-rc3",
"downshift": "3.2.10",
"dropzone": "5.9.2",
"flatpickr": "4.6.9",
"form-data": "4.0.0",

View File

@ -44,15 +44,20 @@ export function QCreateNewButton(): JSX.Element
interface QSaveButtonProps
{
label?: string;
onClickHandler?: any,
disabled: boolean
}
QSaveButton.defaultProps = {
label: "Save"
};
export function QSaveButton({disabled}: QSaveButtonProps): JSX.Element
export function QSaveButton({label, onClickHandler, disabled}: QSaveButtonProps): JSX.Element
{
return (
<MDBox ml={3} width={standardWidth}>
<MDButton type="submit" variant="gradient" color="info" size="small" fullWidth startIcon={<Icon>save</Icon>} disabled={disabled}>
Save
<MDButton type="submit" variant="gradient" color="info" size="small" onClick={onClickHandler} fullWidth startIcon={<Icon>save</Icon>} disabled={disabled}>
{label}
</MDButton>
</MDBox>
);

View File

@ -0,0 +1,169 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Chip} from "@mui/material";
import TextField from "@mui/material/TextField";
import {makeStyles} from "@mui/styles";
import Downshift from "downshift";
import {arrayOf, func, string} from "prop-types";
import React, {useEffect, useState} from "react";
const useStyles = makeStyles((theme: any) => ({
chip: {
margin: theme.spacing(0.5, 0.25)
}
}));
function ChipTextField({...props})
{
const classes = useStyles();
const {handleChipChange, label, chipType, disabled, placeholder, chipData, multiline, rows, ...other} = props;
const [inputValue, setInputValue] = useState("");
const [chips, setChips] = useState([]);
useEffect(() =>
{
setChips(chipData);
}, [chipData]);
useEffect(() =>
{
handleChipChange(chips);
}, [chips, handleChipChange]);
function handleKeyDown(event: any)
{
if (event.key === "Enter")
{
const newChipList = [...chips];
const duplicatedValues = newChipList.indexOf(
event.target.value.trim()
);
if (duplicatedValues !== -1)
{
setInputValue("");
return;
}
if (!event.target.value.replace(/\s/g, "").length) return;
newChipList.push(event.target.value.trim());
setChips(newChipList);
setInputValue("");
}
else if (chips.length && !inputValue.length && event.key === "Backspace" )
{
setChips(chips.slice(0, chips.length - 1));
}
}
function handleChange(item: any)
{
let newChipList = [...chips];
if (newChipList.indexOf(item) === -1)
{
newChipList = [...newChipList, item];
}
setInputValue("");
setChips(newChipList);
}
const handleDelete = (item: any) => () =>
{
const newChipList = [...chips];
newChipList.splice(newChipList.indexOf(item), 1);
setChips(newChipList);
};
function handleInputChange(event: { target: { value: React.SetStateAction<string>; }; })
{
setInputValue(event.target.value);
}
return (
<React.Fragment>
<Downshift
id="downshift-multiple"
inputValue={inputValue}
onChange={handleChange}
selectedItem={chips}
>
{({getInputProps}) =>
{
const {onBlur, onChange, onFocus} = getInputProps({
onKeyDown: handleKeyDown,
placeholder
});
// @ts-ignore
return (
<div id="chip-text-field-container" style={{flexWrap: "wrap", display:"flex"}}>
<TextField
sx={{width: "100%"}}
disabled={disabled}
label={label}
InputProps={{
startAdornment:
<div>
{
chips.map((item, i) => (
<Chip
color={(chipType !== "number" || ! Number.isNaN(Number(item))) ? "info" : "error"}
key={`${item}-${i}`}
variant="outlined"
tabIndex={-1}
label={item}
className={classes.chip}
/>
))
}
</div>,
onBlur,
multiline,
rows,
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
{
handleInputChange(event);
onChange(event);
},
onFocus,
placeholder,
onKeyDown: handleKeyDown
}}
/>
</div>
);
}}
</Downshift>
</React.Fragment>
);
}
ChipTextField.defaultProps = {
chipData: []
};
ChipTextField.propTypes = {
handleChipChange: func.isRequired,
chipData: arrayOf(string)
};
export default ChipTextField

View File

@ -21,16 +21,434 @@
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {TextFieldProps} from "@mui/material";
import {FormControl, InputLabel, Select, SelectChangeEvent, TextFieldProps} from "@mui/material";
import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
import Grid from "@mui/material/Grid";
import Icon from "@mui/material/Icon";
import Modal from "@mui/material/Modal";
import TextField from "@mui/material/TextField";
import {getGridNumericOperators, getGridStringOperators, GridColDef, GridFilterInputValueProps, GridFilterItem} from "@mui/x-data-grid-pro";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import {getGridNumericOperators, getGridStringOperators, GridColDef, GridFilterInputMultipleValue, GridFilterInputMultipleValueProps, GridFilterInputValueProps, GridFilterItem} from "@mui/x-data-grid-pro";
import {GridFilterInputValue} from "@mui/x-data-grid/components/panel/filterPanel/GridFilterInputValue";
import {GridApiCommunity} from "@mui/x-data-grid/internals";
import {GridFilterOperator} from "@mui/x-data-grid/models/gridFilterOperator";
import React, {useEffect, useRef, useState} from "react";
import {QCancelButton, QSaveButton} from "qqq/components/QButtons";
import QDynamicSelect from "qqq/components/QDynamicSelect/QDynamicSelect";
import ChipTextField from "qqq/pages/entity-list/ChipTextField";
////////////////////////////////
// input element for 'is any' //
////////////////////////////////
function CustomIsAnyInput(type: "number" | "text", props: GridFilterInputValueProps)
{
enum Delimiter
{
DETECT_AUTOMATICALLY = "Detect Automatically",
COMMA = "Comma",
NEWLINE = "Newline",
PIPE = "Pipe",
SPACE = "Space",
TAB = "Tab",
CUSTOM = "Custom",
}
const delimiterToCharacterMap: {[key: string]: string} = {};
delimiterToCharacterMap[Delimiter.COMMA] = "[,\n\r]";
delimiterToCharacterMap[Delimiter.TAB] = "[\t,\n,\r]";
delimiterToCharacterMap[Delimiter.NEWLINE] = "[\n\r]";
delimiterToCharacterMap[Delimiter.PIPE] = "[\\|\r\n]";
delimiterToCharacterMap[Delimiter.SPACE] = "[ \n\r]";
const delimiterDropdownOptions = Object.values(Delimiter);
const mainCardStyles: any = {};
mainCardStyles.width = "60%";
mainCardStyles.minWidth = "500px";
const [gridFilterItem, setGridFilterItem] = useState(props.item);
const [pasteModalIsOpen, setPasteModalIsOpen] = useState(false);
const [inputText, setInputText] = useState("");
const [delimiter, setDelimiter] = useState("");
const [delimiterCharacter, setDelimiterCharacter] = useState("");
const [customDelimiterValue, setCustomDelimiterValue] = useState("");
const [chipData, setChipData] = useState(undefined);
const [detectedText, setDetectedText] = useState("");
const [errorText, setErrorText] = useState("");
//////////////////////////////////////////////////////////////
// handler for when paste icon is clicked in 'any' operator //
//////////////////////////////////////////////////////////////
const handlePasteClick = (event: any) =>
{
event.target.blur();
setPasteModalIsOpen(true);
}
const applyValue = (item: GridFilterItem) =>
{
console.log(`updating grid values: ${JSON.stringify(item.value)}`);
setGridFilterItem(item);
props.applyValue(item);
}
const clearData = () =>
{
setDelimiter("");
setDelimiterCharacter("");
setChipData([]);
setInputText("");
setDetectedText("");
setCustomDelimiterValue("");
setPasteModalIsOpen(false)
};
const handleCancelClicked = () =>
{
clearData();
setPasteModalIsOpen(false);
};
const handleSaveClicked = () =>
{
if(gridFilterItem)
{
////////////////////////////////////////
// if numeric remove any non-numerics //
////////////////////////////////////////
let saveData = [];
for(let i=0; i<chipData.length; i++)
{
if(type !== "number" || ! Number.isNaN(Number(chipData[i])))
{
saveData.push(chipData[i]);
}
}
if(gridFilterItem.value)
{
gridFilterItem.value = [...gridFilterItem.value, ...saveData];
}
else
{
gridFilterItem.value = saveData;
}
setGridFilterItem(gridFilterItem);
props.applyValue(gridFilterItem);
}
clearData();
setPasteModalIsOpen(false);
};
////////////////////////////////////////////////////////////////
// when user selects a different delimiter on the parse modal //
////////////////////////////////////////////////////////////////
const handleDelimiterChange = (event: SelectChangeEvent) =>
{
const newDelimiter = event.target.value;
console.log(`Delimiter Changed to ${JSON.stringify(newDelimiter)}`);
setDelimiter(newDelimiter);
if(newDelimiter === Delimiter.CUSTOM)
{
setDelimiterCharacter(customDelimiterValue);
}
else
{
setDelimiterCharacter(delimiterToCharacterMap[newDelimiter]);
}
};
const handleTextChange = (event: any) =>
{
const inputText = event.target.value;
setInputText(inputText);
};
const handleCustomDelimiterChange = (event: any) =>
{
let inputText = event.target.value;
setCustomDelimiterValue(inputText);
};
///////////////////////////////////////////////////////////////////////////////////////
// iterate over each character, putting them into 'buckets' so that we can determine //
// a good default to use when data is pasted into the textarea //
///////////////////////////////////////////////////////////////////////////////////////
const calculateAutomaticDelimiter = (text: string): string =>
{
const buckets = new Map();
for(let i=0; i<text.length; i++)
{
let bucketName = "";
switch(text.charAt(i))
{
case "\t":
bucketName = Delimiter.TAB;
break;
case "\n":
case "\r":
bucketName = Delimiter.NEWLINE;
break;
case "|":
bucketName = Delimiter.PIPE;
break;
case " ":
bucketName = Delimiter.SPACE;
break;
case ",":
bucketName = Delimiter.COMMA;
break;
}
if(bucketName !== "")
{
let currentCount = (buckets.has(bucketName)) ? buckets.get(bucketName) : 0;
buckets.set(bucketName, currentCount + 1);
}
}
///////////////////////
// default is commas //
///////////////////////
let highestCount = 0;
let delimiter = Delimiter.COMMA;
for(let j = 0; j < delimiterDropdownOptions.length; j++)
{
let bucketName = delimiterDropdownOptions[j];
if(buckets.has(bucketName) && buckets.get(bucketName) > highestCount)
{
delimiter = bucketName;
highestCount = buckets.get(bucketName);
}
}
setDetectedText(`${delimiter} Detected`);
return(delimiterToCharacterMap[delimiter]);
};
useEffect(() =>
{
let currentDelimiter = delimiter;
let currentDelimiterCharacter = delimiterCharacter;
/////////////////////////////////////////////////////////////////////////////
// if no delimiter already set in the state, call function to determine it //
/////////////////////////////////////////////////////////////////////////////
if (! currentDelimiter || currentDelimiter === Delimiter.DETECT_AUTOMATICALLY)
{
currentDelimiterCharacter = calculateAutomaticDelimiter(inputText);
if(! currentDelimiterCharacter)
{
return;
}
currentDelimiter = Delimiter.DETECT_AUTOMATICALLY;
setDelimiter(Delimiter.DETECT_AUTOMATICALLY);
setDelimiterCharacter(currentDelimiterCharacter);
}
else if(currentDelimiter === Delimiter.CUSTOM)
{
////////////////////////////////////////////////////
// if custom, make sure to split on new lines too //
////////////////////////////////////////////////////
currentDelimiterCharacter = `[${customDelimiterValue}\r\n]`;
}
console.log(`current delimiter is: ${currentDelimiter}, delimiting on: ${currentDelimiterCharacter}`);
let regex = new RegExp(currentDelimiterCharacter)
let parts = inputText.split(regex);
let chipData = [] as string[];
///////////////////////////////////////////////////////
// if delimiter is empty string, dont split anything //
///////////////////////////////////////////////////////
setErrorText("");
if(currentDelimiterCharacter !== "")
{
for (let i = 0; i < parts.length; i++)
{
let part = parts[i].trim();
if (part !== "")
{
chipData.push(part);
///////////////////////////////////////////////////////////
// if numeric, check that first before pushing as a chip //
///////////////////////////////////////////////////////////
if(type === "number" || Number.isNaN(Number(part)))
{
setErrorText("Some values are not numbers");
}
}
}
}
setChipData(chipData);
}, [inputText, delimiterCharacter, customDelimiterValue, detectedText]);
return (
<Box>
{
props &&
(
<Box id="testId" sx={{width: "100%", display: "inline-flex", flexDirection: "row", alignItems: "end", height: 48}} >
<GridFilterInputMultipleValue
sx={{width: "100%"}}
variant="standard"
type={type} {...props}
applyValue={applyValue}
item={gridFilterItem}
/>
<Tooltip title="Quickly add many values to your filter by pasting them from a spreadsheet or any other data source.">
<Icon onClick={handlePasteClick} fontSize="small" color="info" sx={{marginLeft: "10px", cursor: "pointer"}}>paste_content</Icon>
</Tooltip>
</Box>
)
}
{
pasteModalIsOpen &&
(
<Modal open={pasteModalIsOpen}>
<Box sx={{position: "absolute", overflowY: "auto", width: "100%"}}>
<Box py={3} justifyContent="center" sx={{display: "flex", mt: 8}}>
<Card sx={mainCardStyles}>
<Box p={4} pb={2}>
<Grid container>
<Grid item pr={3} xs={12} lg={12}>
<Typography variant="h5">Bulk Add Filter Values</Typography>
<Typography sx={{display: "flex", lineHeight: "1.7", textTransform: "revert"}} variant="button">
Paste into the box on the left.
Review the filter values in the box on the right.
If the filter values are not what are expected, try changing the separator using the dropdown below.
</Typography>
</Grid>
</Grid>
</Box>
<Grid container pl={3} pr={3} justifyContent="center" alignItems="stretch" sx={{display: "flex", height: "100%"}}>
<Grid item pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}>
<FormControl sx={{m: 1, width: "100%"}}>
<TextField
id="outlined-multiline-static"
label="PASTE TEXT"
multiline
onChange={handleTextChange}
rows={16}
value={inputText}
/>
</FormControl>
</Grid>
<Grid item xs={6} lg={6} sx={{display: "flex", flexGrow: 1}}>
<FormControl sx={{m: 1, width: "100%"}}>
<ChipTextField
handleChipChange={() =>
{}}
chipData={chipData}
chipType={type}
multiline
fullWidth
variant="outlined"
id="tags"
rows={0}
name="tags"
label="FILTER VALUES REVIEW"
/>
</FormControl>
</Grid>
</Grid>
<Grid container pl={3} pr={3} justifyContent="center" alignItems="stretch" sx={{display: "flex", height: "100%"}}>
<Grid item pl={1} pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}>
<Box sx={{display: "inline-flex", alignItems: "baseline"}}>
<FormControl sx={{mt: 2, width: "50%"}}>
<InputLabel htmlFor="select-native">
SEPARATOR
</InputLabel>
<Select
multiline
native
value={delimiter}
onChange={handleDelimiterChange}
label="SEPARATOR"
size="medium"
inputProps={{
id: "select-native",
}}
>
{delimiterDropdownOptions.map((delimiter) => (
<option key={delimiter} value={delimiter}>
{delimiter}
</option>
))}
</Select>
</FormControl>
{ delimiter === Delimiter.CUSTOM.valueOf() && (
<FormControl sx={{pl: 2, top: 5, width: "50%"}}>
<TextField
name="custom-delimiter-value"
placeholder="Custom Separator"
label="Custom Separator"
variant="standard"
value={customDelimiterValue}
onChange={handleCustomDelimiterChange}
inputProps={{maxLength: 1}}
/>
</FormControl>
)}
{ inputText && delimiter === Delimiter.DETECT_AUTOMATICALLY.valueOf() && (
<Typography pl={2} variant="button" sx={{top: "1px", textTransform: "revert"}}>
<i>{detectedText}</i>
</Typography>
)}
</Box>
</Grid>
<Grid sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}} item pl={1} xs={3} lg={3}>
{
errorText && chipData.length > 0 && (
<Box sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}}>
<Icon color="error">error</Icon>
<Typography sx={{paddingLeft: "4px", textTransform: "revert"}} variant="button">{errorText}</Typography>
</Box>
)
}
</Grid>
<Grid sx={{display: "flex", justifyContent: "flex-end", alignItems: "flex-start"}} item pr={1} xs={3} lg={3}>
{
chipData && chipData.length > 0 && (
<Typography sx={{textTransform: "revert"}} variant="button">{chipData.length.toLocaleString()} {chipData.length === 1 ? "value" : "values"}</Typography>
)
}
</Grid>
</Grid>
<Box p={3} pt={0}>
<Grid container pl={1} pr={1} justifyContent="right" alignItems="stretch" sx={{display: "flex-inline "}}>
<QCancelButton
onClickHandler={handleCancelClicked}
iconName="cancel"
disabled={false} />
<QSaveButton onClickHandler={handleSaveClicked} label="Add Filters" disabled={false}/>
</Grid>
</Box>
</Card>
</Box>
</Box>
</Modal>
)
}
</Box>
);
}
//////////////////////
// string operators //
@ -67,12 +485,25 @@ const stringNotEndWithOperator: GridFilterOperator = {
InputComponent: GridFilterInputValue,
};
const stringIsAnyOfOperator: GridFilterOperator = {
label: "is any of",
value: "isAnyOf",
getApplyFilterFn: () => null,
// @ts-ignore
InputComponent: (props: GridFilterInputMultipleValueProps<GridApiCommunity>) => CustomIsAnyInput("text", props)
};
let gridStringOperators = getGridStringOperators();
let equals = gridStringOperators.splice(1, 1)[0];
let contains = gridStringOperators.splice(0, 1)[0];
let startsWith = gridStringOperators.splice(0, 1)[0];
let endsWith = gridStringOperators.splice(0, 1)[0];
gridStringOperators = [ equals, stringNotEqualsOperator, contains, stringNotContainsOperator, startsWith, stringNotStartsWithOperator, endsWith, stringNotEndWithOperator, ...gridStringOperators ];
///////////////////////////////////
// remove default isany operator //
///////////////////////////////////
gridStringOperators.splice(2, 1)[0];
gridStringOperators = [ equals, stringNotEqualsOperator, contains, stringNotContainsOperator, startsWith, stringNotStartsWithOperator, endsWith, stringNotEndWithOperator, ...gridStringOperators, stringIsAnyOfOperator ];
export const QGridStringOperators = gridStringOperators;
@ -184,8 +615,20 @@ const notBetweenOperator: GridFilterOperator = {
InputComponent: InputNumberInterval
};
export const QGridNumericOperators = [ ...getGridNumericOperators(), betweenOperator, notBetweenOperator ];
const numericIsAnyOfOperator: GridFilterOperator = {
label: "is any of",
value: "isAnyOf",
getApplyFilterFn: () => null,
// @ts-ignore
InputComponent: (props: GridFilterInputMultipleValueProps<GridApiCommunity>) => CustomIsAnyInput("number", props)
};
//////////////////////////////
// remove default is any of //
//////////////////////////////
let gridNumericOperators = getGridNumericOperators();
gridNumericOperators.splice(8, 1)[0];
export const QGridNumericOperators = [ ...gridNumericOperators, betweenOperator, notBetweenOperator, numericIsAnyOfOperator ];
///////////////////////
// boolean operators //
@ -312,7 +755,7 @@ export const buildQGridPvsOperators = (tableName: string, field: QFieldMetaData)
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
},
{
label: "is not empty",
label: "is not Empty",
value: "isNotEmpty",
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
}

View File

@ -37,31 +37,9 @@ import ListItemIcon from "@mui/material/ListItemIcon";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Modal from "@mui/material/Modal";
import {
DataGridPro,
getGridDateOperators,
GridCallbackDetails,
GridColDef,
GridColumnOrderChangeParams,
GridColumnVisibilityModel,
GridExportMenuItemProps,
GridFilterModel,
GridLinkOperator,
GridRowId,
GridRowParams,
GridRowsProp,
GridSelectionModel,
GridSortItem,
GridSortModel,
GridToolbarColumnsButton,
GridToolbarContainer,
GridToolbarDensitySelector,
GridToolbarExportContainer,
GridToolbarFilterButton,
MuiEvent
} from "@mui/x-data-grid-pro";
import {DataGridPro, getGridDateOperators, GridCallbackDetails, GridColDef, GridColumnOrderChangeParams, GridColumnVisibilityModel, GridDensity, GridEventListener, GridExportMenuItemProps, GridFilterModel, GridLinkOperator, gridPreferencePanelStateSelector, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, GridToolbarFilterButton, MuiEvent, useGridApiContext, useGridApiEventHandler, useGridSelector} from "@mui/x-data-grid-pro";
import {GridFilterOperator} from "@mui/x-data-grid/models/gridFilterOperator";
import React, {useCallback, useContext, useEffect, useReducer, useRef, useState} from "react";
import React, {useContext, useEffect, useReducer, useRef, useState} from "react";
import {Link, useLocation, useNavigate, useSearchParams} from "react-router-dom";
import QContext from "QContext";
import DashboardLayout from "qqq/components/DashboardLayout";
@ -81,6 +59,7 @@ const COLUMN_VISIBILITY_LOCAL_STORAGE_KEY_ROOT = "qqq.columnVisibility";
const COLUMN_SORT_LOCAL_STORAGE_KEY_ROOT = "qqq.columnSort";
const FILTER_LOCAL_STORAGE_KEY_ROOT = "qqq.filter";
const ROWS_PER_PAGE_LOCAL_STORAGE_KEY_ROOT = "qqq.rowsPerPage";
const DENSITY_LOCAL_STORAGE_KEY_ROOT = "qqq.density";
interface Props
{
@ -187,6 +166,12 @@ function EntityList({table, launchProcess}: Props): JSX.Element
let defaultSort = [] as GridSortItem[];
let defaultVisibility = {};
let defaultRowsPerPage = 10;
let defaultDensity = "standard";
////////////////////////////////////////////////////////////////////////////////////
// set the to be not per table (do as above if we want per table) at a later port //
////////////////////////////////////////////////////////////////////////////////////
const densityLocalStorageKey = `${DENSITY_LOCAL_STORAGE_KEY_ROOT}`;
if (localStorage.getItem(sortLocalStorageKey))
{
@ -200,11 +185,16 @@ function EntityList({table, launchProcess}: Props): JSX.Element
{
defaultRowsPerPage = JSON.parse(localStorage.getItem(rowsPerPageLocalStorageKey));
}
if (localStorage.getItem(densityLocalStorageKey))
{
defaultDensity = JSON.parse(localStorage.getItem(densityLocalStorageKey));
}
const [filterModel, setFilterModel] = useState({items: []} as GridFilterModel);
const [columnSortModel, setColumnSortModel] = useState(defaultSort);
const [columnVisibilityModel, setColumnVisibilityModel] = useState(defaultVisibility);
const [rowsPerPage, setRowsPerPage] = useState(defaultRowsPerPage);
const [density, setDensity] = useState(defaultDensity as GridDensity);
///////////////////////////////////////////////////////////////////////////////////////////////
// for some reason, if we set the filterModel to what is in local storage, an onChange event //
@ -233,6 +223,7 @@ function EntityList({table, launchProcess}: Props): JSX.Element
const [gridMouseDownX, setGridMouseDownX] = useState(0);
const [gridMouseDownY, setGridMouseDownY] = useState(0);
const [pinnedColumns, setPinnedColumns] = useState({left: ["__check__", "id"]});
const [gridPreferencesWindow, setGridPreferencesWindow] = useState(undefined);
const [activeModalProcess, setActiveModalProcess] = useState(null as QProcessMetaData);
const [launchingProcess, setLaunchingProcess] = useState(launchProcess);
@ -313,6 +304,14 @@ function EntityList({table, launchProcess}: Props): JSX.Element
{
filterModel.items.forEach((item) =>
{
////////////////////////////////////////////////////////////////////////////////
// if no value set and not 'empty' or 'not empty' operators, skip this filter //
////////////////////////////////////////////////////////////////////////////////
if(! item.value && item.operatorValue !== "isEmpty" && item.operatorValue !== "isNotEmpty")
{
return;
}
const operator = QFilterUtils.gridCriteriaOperatorToQQQ(item.operatorValue);
const values = QFilterUtils.gridCriteriaValueToQQQ(operator, item.value, item.operatorValue);
qFilter.addCriteria(new QFilterCriteria(item.columnField, operator, values));
@ -434,7 +433,6 @@ function EntityList({table, launchProcess}: Props): JSX.Element
delete countResults[latestQueryId];
}, [ receivedCountTimestamp ]);
///////////////////////////
// display query results //
///////////////////////////
@ -623,8 +621,28 @@ function EntityList({table, launchProcess}: Props): JSX.Element
localStorage.setItem(rowsPerPageLocalStorageKey, JSON.stringify(size));
};
const handleStateChange = (state: GridState, event: MuiEvent, details: GridCallbackDetails) =>
{
if (state && state.density && state.density.value !== density)
{
setDensity(state.density.value);
localStorage.setItem(densityLocalStorageKey, JSON.stringify(state.density.value));
}
};
const handleRowClick = (params: GridRowParams, event: MuiEvent<React.MouseEvent>, details: GridCallbackDetails) =>
{
/////////////////////////////////////////////////////////////////
// if a grid preference window is open, ignore and reset timer //
/////////////////////////////////////////////////////////////////
console.log(gridPreferencesWindow);
if (gridPreferencesWindow !== undefined)
{
clearTimeout(instance.current.timer);
return;
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// strategy for when to trigger or not trigger a row click: //
// To avoid a drag-event that highlighted text in a cell: //
@ -635,35 +653,22 @@ function EntityList({table, launchProcess}: Props): JSX.Element
// - also avoid a click, then click-again-and-start-dragging, by always cancelling the timer in mouse-down. //
// All in, these seem to have good results - the only downside being the half-second delay... //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
navigate(`${params.id}`);
/*
const diff = Math.max(Math.abs(event.clientX - gridMouseDownX), Math.abs(event.clientY - gridMouseDownY));
if (diff < 5)
{
console.log("clearing timeout");
clearTimeout(instance.current.timer);
instance.current.timer = setTimeout(() =>
{
navigate(`${params.id}`);
}, 500);
}, 100);
}
else
{
console.log(`row-click mouse-up happened ${diff} x or y pixels away from the mouse-down - so not considering it a click.`);
}
*/
};
const handleGridMouseDown = useCallback((event: any) =>
{
setGridMouseDownX(event.clientX);
setGridMouseDownY(event.clientY);
clearTimeout(instance.current.timer);
}, []);
const handleGridDoubleClick = useCallback((event: any) =>
{
clearTimeout(instance.current.timer);
}, []);
const selectionChanged = (selectionModel: GridSelectionModel, details: GridCallbackDetails) =>
{
@ -705,6 +710,26 @@ function EntityList({table, launchProcess}: Props): JSX.Element
const handleFilterChange = (filterModel: GridFilterModel) =>
{
////////////////////////////////////////////////////////
// remove any items in the filter that are incomplete //
////////////////////////////////////////////////////////
/*
if(filterModel && filterModel.items)
{
filterModel.items.forEach((item: GridFilterItem, index: number, arr: GridFilterItem[]) =>
{
if(! item.value)
{
if( item.operatorValue !== "isEmpty" && item.operatorValue !== "isNotEmpty")
{
arr.splice(index, 1);
}
}
})
}
*/
setFilterModel(filterModel);
if (filterLocalStorageKey)
{
@ -977,6 +1002,36 @@ function EntityList({table, launchProcess}: Props): JSX.Element
function CustomToolbar()
{
const handleMouseDown: GridEventListener<"cellMouseDown"> = (
params, // GridRowParams
event, // MuiEvent<React.MouseEvent<HTMLElement>>
details, // GridCallbackDetails
) =>
{
setGridMouseDownX(event.clientX);
setGridMouseDownY(event.clientY);
clearTimeout(instance.current.timer);
};
const handleDoubleClick: GridEventListener<"rowDoubleClick"> = (event: any) =>
{
clearTimeout(instance.current.timer);
};
const apiRef = useGridApiContext();
useGridApiEventHandler(apiRef, "cellMouseDown", handleMouseDown);
useGridApiEventHandler(apiRef, "rowDoubleClick", handleDoubleClick);
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// keep track of any preference windows that are opened in the toolbar, to allow ignoring clicks away from the window //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
const preferencePanelState = useGridSelector(apiRef, gridPreferencePanelStateSelector);
setGridPreferencesWindow(preferencePanelState.openedPanelValue);
});
return (
<GridToolbarContainer>
<div>
@ -1093,6 +1148,7 @@ function EntityList({table, launchProcess}: Props): JSX.Element
document.scrollingElement.scrollTop = 0;
}, [ pageNumber, rowsPerPage ]);
return (
<DashboardLayout>
<Navbar />
@ -1130,8 +1186,6 @@ function EntityList({table, launchProcess}: Props): JSX.Element
</MDBox>
<Card>
{/* with these turned on, the toolbar & pagination controls become very flaky...
onMouseDown={(e) => handleGridMouseDown(e)} onDoubleClick={(e) => handleGridDoubleClick(e)} */}
<MDBox height="100%">
<DataGridPro
components={{Toolbar: CustomToolbar, Pagination: CustomPagination, LoadingOverlay: Loading}}
@ -1150,7 +1204,8 @@ function EntityList({table, launchProcess}: Props): JSX.Element
rowCount={totalRecords === null ? 0 : totalRecords}
onPageSizeChange={handleRowsPerPageChange}
onRowClick={handleRowClick}
density="standard"
onStateChange={handleStateChange}
density={density}
loading={loading}
filterModel={filterModel}
onFilterModelChange={handleFilterChange}

View File

@ -183,3 +183,32 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
{
margin-bottom: 24px;
}
#chip-text-field-container > div > div
{
background: #F8F8F8;
height: 330px;
padding: 10px;
display: flex;
flex-wrap: wrap;
}
#chip-text-field-container > div > div > textarea
{
display: none;
}
#chip-text-field-container > div > div > div
{
height: 100%;
overflow: scroll;
}
#outlined-multiline-static
{
padding-left: 10px;
padding-top: 0px;
padding-right: 10px;
padding-bottom: 0px;
margin-top: 10px;
}