/* * 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 . */ 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; fieldLabel: string; inForm: boolean; initialValue?: any; initialValues?: QPossibleValue[]; onChange?: any; isEditable?: boolean; isMultiple?: boolean; bulkEditMode?: boolean; bulkEditSwitchChangeHandler?: any; otherValues?: Map; 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(), 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, 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([]); 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 => { if(possibleValues) { return filterInlinePossibleValues(searchTerm, possibleValues) } else { return await qController.possibleValues(tableName, processName, possibleValueSourceName ?? fieldName, searchTerm ?? "", 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 ? {iconName} : null; content = (); } } } catch (e) { } if (isMultiple) { content = ( <> {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 (
  • {content}
  • ); }; /*************************************************************************** ** ***************************************************************************/ 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 = ( { 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) => ( {loading ? : null} {params.InputProps.endAdornment} ), }} /> )} /> { inForm && {!isDisabled &&
    {msg}} />
    }
    }
    ); if (bulkEditMode) { return ( {autocomplete} ); } else { return ( {autocomplete} ); } } export default DynamicSelect;