mirror of
https://github.com/Kingsrook/qqq-frontend-material-dashboard.git
synced 2025-07-17 21:00:45 +00:00
432 lines
14 KiB
TypeScript
432 lines
14 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 React, {useEffect, useState} from "react";
|
|
import colors from "qqq/assets/theme/base/colors";
|
|
import MDTypography from "qqq/components/legacy/MDTypography";
|
|
import Client from "qqq/utils/qqq/Client";
|
|
|
|
interface Props
|
|
{
|
|
tableName?: string;
|
|
processName?: string;
|
|
fieldName: string;
|
|
overrideId?: string;
|
|
fieldLabel: string;
|
|
inForm: boolean;
|
|
initialValue?: any;
|
|
initialDisplayValue?: string;
|
|
initialValues?: QPossibleValue[];
|
|
onChange?: any;
|
|
isEditable?: boolean;
|
|
isMultiple?: boolean;
|
|
bulkEditMode?: boolean;
|
|
bulkEditSwitchChangeHandler?: any;
|
|
otherValues?: Map<string, any>;
|
|
variant: "standard" | "outlined";
|
|
initiallyOpen: boolean;
|
|
}
|
|
|
|
DynamicSelect.defaultProps = {
|
|
tableName: null,
|
|
processName: null,
|
|
inForm: true,
|
|
initialValue: null,
|
|
initialDisplayValue: null,
|
|
initialValues: undefined,
|
|
onChange: null,
|
|
isEditable: true,
|
|
isMultiple: false,
|
|
bulkEditMode: false,
|
|
otherValues: new Map<string, any>(),
|
|
variant: "outlined",
|
|
initiallyOpen: false,
|
|
bulkEditSwitchChangeHandler: () =>
|
|
{
|
|
},
|
|
};
|
|
|
|
const qController = Client.getInstance();
|
|
|
|
function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabel, inForm, initialValue, initialDisplayValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant, initiallyOpen}: Props)
|
|
{
|
|
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))))
|
|
const {inputBorderColor} = colors;
|
|
|
|
////////////////////////////////////////////////////////////////////////////////////////////////
|
|
// 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;
|
|
}
|
|
|
|
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 qController.possibleValues(tableName, processName, fieldName, searchTerm ?? "", null, otherValues);
|
|
|
|
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 qController.possibleValues(tableName, processName, fieldName, searchTerm ?? "", null, otherValues);
|
|
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)
|
|
{
|
|
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 = {
|
|
"& .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 autocomplete = (
|
|
<Box>
|
|
<Autocomplete
|
|
id={overrideId ?? fieldName}
|
|
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={fieldName} 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 mb={1.5}>
|
|
{autocomplete}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
|
|
}
|
|
|
|
export default DynamicSelect;
|