Files
qqq-frontend-material-dashb…/src/qqq/components/forms/DynamicSelect.tsx

535 lines
18 KiB
TypeScript

/*
* 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 {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {Checkbox, Chip, CircularProgress, FilterOptionsState, Icon} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField";
import {ErrorMessage, useFormikContext} from "formik";
import colors from "qqq/assets/theme/base/colors";
import MDTypography from "qqq/components/legacy/MDTypography";
import {FieldPossibleValueProps} from "qqq/models/fields/FieldPossibleValueProps";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useState} from "react";
interface Props
{
fieldPossibleValueProps: FieldPossibleValueProps;
overrideId?: string;
name?: string;
fieldLabel: string;
inForm: boolean;
initialValue?: any;
initialValues?: QPossibleValue[];
onChange?: any;
isEditable?: boolean;
isMultiple?: boolean;
bulkEditMode?: boolean;
bulkEditSwitchChangeHandler?: any;
otherValues?: Map<string, any>;
variant: "standard" | "outlined";
initiallyOpen: boolean;
useCase: "form" | "filter";
}
DynamicSelect.defaultProps = {
inForm: true,
initialValue: null,
initialValues: undefined,
onChange: null,
isEditable: true,
isMultiple: false,
bulkEditMode: false,
otherValues: new Map<string, any>(),
variant: "outlined",
initiallyOpen: false,
bulkEditSwitchChangeHandler: () =>
{
},
};
const {inputBorderColor} = colors;
export const getAutocompleteOutlinedStyle = (isDisabled: boolean) =>
{
return ({
"& .MuiOutlinedInput-root": {
borderRadius: "0.75rem",
},
"& .MuiInputBase-root": {
padding: "0.5rem",
background: isDisabled ? "#f0f2f5!important" : "initial",
},
"& .MuiOutlinedInput-root .MuiAutocomplete-input": {
padding: "0",
fontSize: "1rem"
},
"& .Mui-disabled .MuiOutlinedInput-notchedOutline": {
borderColor: inputBorderColor
}
});
};
const qController = Client.getInstance();
function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, inForm, initialValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant, initiallyOpen, useCase}: Props)
{
const {fieldName, initialDisplayValue, possibleValueSourceName, possibleValues, processName, tableName} = fieldPossibleValueProps;
const [open, setOpen] = useState(initiallyOpen);
const [options, setOptions] = useState<readonly QPossibleValue[]>([]);
const [searchTerm, setSearchTerm] = useState(null);
const [firstRender, setFirstRender] = useState(true);
const [otherValuesWhenResultsWereLoaded, setOtherValuesWhenResultsWereLoaded] = useState(JSON.stringify(Object.fromEntries((otherValues))));
useEffect(() =>
{
if (tableName && processName)
{
console.log("DynamicSelect - you may not provide both a tableName and a processName");
}
if (tableName && !fieldName)
{
console.log("DynamicSelect - if you provide a tableName, you must also provide a fieldName");
}
if (processName && !fieldName)
{
console.log("DynamicSelect - if you provide a processName, you must also provide a fieldName");
}
if (!fieldName && !possibleValueSourceName)
{
console.log("DynamicSelect - you must provide either a fieldName (and a tableName or processName) or a possibleValueSourceName");
}
if (fieldName && !possibleValueSourceName)
{
if (!tableName || !processName)
{
console.log("DynamicSelect - if you provide a fieldName and not a possibleValueSourceName, then you must also provide a tableName or processName");
}
}
if (possibleValueSourceName)
{
if (tableName || processName)
{
console.log("DynamicSelect - if you provide a possibleValueSourceName, you should not also provide a tableName or processName");
}
}
}, [tableName, processName, fieldName, possibleValueSourceName]);
////////////////////////////////////////////////////////////////////////////////////////////////
// default value - needs to be an array (from initialValues (array) prop) for multiple mode - //
// else non-multiple, assume we took in an initialValue (id) and initialDisplayValue (label), //
// and build a little object that looks like a possibleValue out of those //
////////////////////////////////////////////////////////////////////////////////////////////////
let [defaultValue, _] = isMultiple ? useState(initialValues ?? undefined)
: useState(initialValue && initialDisplayValue ? [{id: initialValue, label: initialDisplayValue}] : null);
if (isMultiple && defaultValue === null)
{
defaultValue = [];
}
// const loading = open && options.length === 0;
const [loading, setLoading] = useState(false);
const [switchChecked, setSwitchChecked] = useState(false);
const [isDisabled, setIsDisabled] = useState(!isEditable || bulkEditMode);
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
let setFieldValueRef: (field: string, value: any, shouldValidate?: boolean) => void = null;
if (inForm)
{
const {setFieldValue} = useFormikContext();
setFieldValueRef = setFieldValue;
}
/*******************************************************************************
**
*******************************************************************************/
const filterInlinePossibleValues = (searchTerm: string, possibleValues: QPossibleValue[]): QPossibleValue[] =>
{
return possibleValues.filter(pv => pv.label?.toLowerCase().startsWith(searchTerm));
};
/***************************************************************************
**
***************************************************************************/
const loadResults = async (): Promise<QPossibleValue[]> =>
{
if (possibleValues)
{
return filterInlinePossibleValues(searchTerm, possibleValues);
}
else
{
return await qController.possibleValues(tableName, processName, possibleValueSourceName ?? fieldName, searchTerm ?? "", null, null, otherValues, useCase);
}
};
/***************************************************************************
**
***************************************************************************/
useEffect(() =>
{
if (firstRender)
{
// console.log("First render, so not searching...");
setFirstRender(false);
/*
if(!initiallyOpen)
{
console.log("returning because not initially open?");
return;
}
*/
}
// console.log("Use effect for searchTerm - searching!");
let active = true;
setLoading(true);
(async () =>
{
// console.log(`doing a search with ${searchTerm}`);
const results: QPossibleValue[] = await loadResults();
if (tableMetaData == null && tableName)
{
let tableMetaData: QTableMetaData = await qController.loadTableMetaData(tableName);
setTableMetaData(tableMetaData);
}
setLoading(false);
// console.log("Results:")
// console.log(`${results}`);
if (active)
{
setOptions([...results]);
}
})();
return () =>
{
active = false;
};
}, [searchTerm]);
/***************************************************************************
** todo - finish... call it in onOpen?
***************************************************************************/
const reloadIfOtherValuesAreChanged = () =>
{
if (JSON.stringify(Object.fromEntries(otherValues)) != otherValuesWhenResultsWereLoaded)
{
(async () =>
{
setLoading(true);
setOptions([]);
console.log("Refreshing possible values...");
const results: QPossibleValue[] = await loadResults();
setLoading(false);
setOptions([...results]);
setOtherValuesWhenResultsWereLoaded(JSON.stringify(Object.fromEntries(otherValues)));
})();
}
};
/***************************************************************************
**
***************************************************************************/
const inputChanged = (event: React.SyntheticEvent, value: string, reason: string) =>
{
// console.log(`input changed. Reason: ${reason}, setting search term to ${value}`);
if (reason !== "reset")
{
// console.log(` -> setting search term to ${value}`);
setSearchTerm(value);
}
};
/***************************************************************************
**
***************************************************************************/
const handleBlur = (x: any) =>
{
setSearchTerm(null);
};
/***************************************************************************
**
***************************************************************************/
const handleChanged = (event: React.SyntheticEvent, value: any | any[], reason: string, details?: string) =>
{
// console.log("handleChanged. value is:");
// console.log(value);
setSearchTerm(null);
if (onChange)
{
if (isMultiple)
{
onChange(value);
}
else
{
onChange(value ? new QPossibleValue(value) : null);
}
}
else if (setFieldValueRef && fieldName)
{
setFieldValueRef(fieldName, value ? value.id : null);
}
};
/***************************************************************************
**
***************************************************************************/
const filterOptions = (options: { id: any; label: string; }[], state: FilterOptionsState<{ id: any; label: string; }>): { id: any; label: string; }[] =>
{
/////////////////////////////////////////////////////////////////////////////////
// this looks like a no-op, but it's important to have, otherwise, we can only //
// get options whose text/label matches the input (e.g., not ids that match) //
/////////////////////////////////////////////////////////////////////////////////
return (options);
};
/***************************************************************************
**
***************************************************************************/
// @ts-ignore
const renderOption = (props: Object, option: any, {selected}) =>
{
let content = (<>{option.label}</>);
try
{
const field = tableMetaData?.fields.get(fieldName);
if (field)
{
const adornment = field.getAdornment(AdornmentType.CHIP);
if (adornment)
{
const color = adornment.getValue("color." + option.id) ?? "default";
const iconName = adornment.getValue("icon." + option.id) ?? null;
const iconElement = iconName ? <Icon>{iconName}</Icon> : null;
content = (<Chip label={option.label} color={color} icon={iconElement} size="small" variant="outlined" sx={{fontWeight: 500}} />);
}
}
}
catch (e)
{
}
if (isMultiple)
{
content = (
<>
<Checkbox style={{marginRight: 8}} checked={selected} />
{content}
</>
);
}
///////////////////////////////////////////////////////////////////////////////////////////////
// we provide a custom renderOption method, to prevent a bug we saw during development, //
// where if multiple options had an identical label, then the widget would ... i don't know, //
// show more options than it should - it was odd to see, and it could be fixed by changing //
// a PVS's format to include id - so the idea came, that maybe the LI's needed unique key //
// attributes. so, doing this, w/ key=id, seemed to fix it. //
///////////////////////////////////////////////////////////////////////////////////////////////
return (
<li {...props} key={option.id} style={{fontSize: "1rem"}}>
{content}
</li>
);
};
/***************************************************************************
**
***************************************************************************/
const bulkEditSwitchChanged = () =>
{
const newSwitchValue = !switchChecked;
setSwitchChecked(newSwitchValue);
setIsDisabled(!newSwitchValue);
bulkEditSwitchChangeHandler(fieldName, newSwitchValue);
};
////////////////////////////////////////////
// for outlined style, adjust some styles //
////////////////////////////////////////////
let autocompleteSX = {};
if (variant == "outlined")
{
autocompleteSX = getAutocompleteOutlinedStyle(isDisabled);
}
const autocomplete = (
<Box>
<Autocomplete
id={overrideId ?? fieldName ?? possibleValueSourceName ?? "anonymous"}
name={name}
sx={autocompleteSX}
open={open}
fullWidth
onOpen={() =>
{
setOpen(true);
// console.log("setting open...");
if (options.length == 0)
{
// console.log("no options yet, so setting search term to ''...");
setSearchTerm("");
}
}}
onClose={() =>
{
setOpen(false);
}}
isOptionEqualToValue={(option, value) => value !== null && value !== undefined && option.id === value.id}
getOptionLabel={(option) =>
{
if (option === null || option === undefined)
{
return ("");
}
// @ts-ignore
if (option && option.length)
{
// @ts-ignore
option = option[0];
}
// @ts-ignore
return option.label;
}}
options={options}
loading={loading}
onInputChange={inputChanged}
onBlur={handleBlur}
defaultValue={defaultValue}
// @ts-ignore
onChange={handleChanged}
noOptionsText={"No matches found"}
onKeyPress={e =>
{
if (e.key === "Enter")
{
e.preventDefault();
}
}}
renderOption={renderOption}
filterOptions={filterOptions}
disabled={isDisabled}
multiple={isMultiple}
disableCloseOnSelect={isMultiple}
limitTags={5}
slotProps={{popper: {className: "DynamicSelectPopper"}}}
renderInput={(params) => (
<TextField
{...params}
label={fieldLabel}
variant={variant}
autoComplete="off"
type="search"
InputProps={{
...params.InputProps,
endAdornment: (
<React.Fragment>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/>
)}
/>
{
inForm &&
<Box mt={0.75}>
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
{!isDisabled && <div className="fieldErrorMessage"><ErrorMessage name={overrideId ?? fieldName ?? possibleValueSourceName ?? "anonymous"} render={msg => <span data-field-error="true">{msg}</span>} /></div>}
</MDTypography>
</Box>
}
</Box>
);
if (bulkEditMode)
{
return (
<Box mb={1.5} display="flex" flexDirection="row">
<Box alignItems="baseline" pt={1}>
<Switch
id={`bulkEditSwitch-${fieldName}`}
checked={switchChecked}
onClick={bulkEditSwitchChanged}
sx={{
top: "-4px",
"& .MuiSwitch-track": {
height: 20,
borderRadius: 10,
top: -3,
position: "relative"
}
}}
/>
</Box>
<Box width="100%">
{autocomplete}
</Box>
</Box>
);
}
else
{
return (
<Box>
{autocomplete}
</Box>
);
}
}
export default DynamicSelect;