mirror of
https://github.com/Kingsrook/qqq-frontend-material-dashboard.git
synced 2025-07-17 21:00:45 +00:00
New style & functionality (null-label, default-from-data) for widget dropdowns
This commit is contained in:
@ -25,14 +25,13 @@ import Box from "@mui/material/Box";
|
|||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Card from "@mui/material/Card";
|
import Card from "@mui/material/Card";
|
||||||
import Icon from "@mui/material/Icon";
|
import Icon from "@mui/material/Icon";
|
||||||
import LinearProgress from "@mui/material/LinearProgress";
|
|
||||||
import Tooltip from "@mui/material/Tooltip/Tooltip";
|
import Tooltip from "@mui/material/Tooltip/Tooltip";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import parse from "html-react-parser";
|
import parse from "html-react-parser";
|
||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {NavigateFunction, useNavigate} from "react-router-dom";
|
import {NavigateFunction, useNavigate} from "react-router-dom";
|
||||||
import colors from "qqq/assets/theme/base/colors";
|
import colors from "qqq/assets/theme/base/colors";
|
||||||
import DropdownMenu, {DropdownOption} from "qqq/components/widgets/components/DropdownMenu";
|
import WidgetDropdownMenu, {DropdownOption} from "qqq/components/widgets/components/WidgetDropdownMenu";
|
||||||
|
|
||||||
export interface WidgetData
|
export interface WidgetData
|
||||||
{
|
{
|
||||||
@ -43,6 +42,7 @@ export interface WidgetData
|
|||||||
id: string,
|
id: string,
|
||||||
label: string
|
label: string
|
||||||
}[][];
|
}[][];
|
||||||
|
dropdownDefaultValueList?: string[];
|
||||||
dropdownNeedsSelectedText?: string;
|
dropdownNeedsSelectedText?: string;
|
||||||
hasPermission?: boolean;
|
hasPermission?: boolean;
|
||||||
errorLoading?: boolean;
|
errorLoading?: boolean;
|
||||||
@ -134,15 +134,15 @@ export class HeaderIcon extends LabelComponent
|
|||||||
borderRadius: "0.25rem"
|
borderRadius: "0.25rem"
|
||||||
};
|
};
|
||||||
|
|
||||||
if(this.iconPath)
|
if (this.iconPath)
|
||||||
{
|
{
|
||||||
return (<Box sx={{textAlign: "center", ...styles}}><img src={this.iconPath} width="16" height="16" /></Box>)
|
return (<Box sx={{textAlign: "center", ...styles}}><img src={this.iconPath} width="16" height="16" /></Box>);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return (<Icon sx={{padding: "0.25rem", ...styles}} fontSize="small">{this.iconName}</Icon>);
|
return (<Icon sx={{padding: "0.25rem", ...styles}} fontSize="small">{this.iconName}</Icon>);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -188,41 +188,111 @@ export class AddNewRecordButton extends LabelComponent
|
|||||||
export class Dropdown extends LabelComponent
|
export class Dropdown extends LabelComponent
|
||||||
{
|
{
|
||||||
label: string;
|
label: string;
|
||||||
|
dropdownMetaData: any;
|
||||||
options: DropdownOption[];
|
options: DropdownOption[];
|
||||||
|
dropdownDefaultValue?: string;
|
||||||
dropdownName: string;
|
dropdownName: string;
|
||||||
onChangeCallback: any;
|
onChangeCallback: any;
|
||||||
|
|
||||||
constructor(label: string, options: DropdownOption[], dropdownName: string, onChangeCallback: any)
|
constructor(label: string, dropdownMetaData: any, options: DropdownOption[], dropdownDefaultValue: string, dropdownName: string, onChangeCallback: any)
|
||||||
{
|
{
|
||||||
super();
|
super();
|
||||||
this.label = label;
|
this.label = label;
|
||||||
|
this.dropdownMetaData = dropdownMetaData;
|
||||||
this.options = options;
|
this.options = options;
|
||||||
|
this.dropdownDefaultValue = dropdownDefaultValue;
|
||||||
this.dropdownName = dropdownName;
|
this.dropdownName = dropdownName;
|
||||||
this.onChangeCallback = onChangeCallback;
|
this.onChangeCallback = onChangeCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
render = (args: LabelComponentRenderArgs): JSX.Element =>
|
render = (args: LabelComponentRenderArgs): JSX.Element =>
|
||||||
{
|
{
|
||||||
|
const label = `Select ${this.label}`;
|
||||||
let defaultValue = null;
|
let defaultValue = null;
|
||||||
const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${args.widgetProps.widgetMetaData.name}.${this.dropdownName}`;
|
const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${args.widgetProps.widgetMetaData.name}.${this.dropdownName}`;
|
||||||
if (args.widgetProps.storeDropdownSelections)
|
if (args.widgetProps.storeDropdownSelections)
|
||||||
{
|
{
|
||||||
///////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
// see if an existing value is stored in local storage, and if so set it in dropdown //
|
// see if an existing value is stored in local storage, and if so set it in dropdown //
|
||||||
///////////////////////////////////////////////////////////////////////////////////////
|
// originally we used the full object from localStorage - but - in case the label //
|
||||||
defaultValue = JSON.parse(localStorage.getItem(localStorageKey));
|
// changed since it was stored, we'll instead just find the option by id (or in case that //
|
||||||
args.dropdownData[args.componentIndex] = defaultValue?.id;
|
// option isn't available anymore, then we'll select nothing instead of a missing value //
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const localStorageOption = JSON.parse(localStorage.getItem(localStorageKey));
|
||||||
|
if(localStorageOption)
|
||||||
|
{
|
||||||
|
const id = localStorageOption.id;
|
||||||
|
for (let i = 0; i < this.options.length; i++)
|
||||||
|
{
|
||||||
|
if (this.options[i].id == id)
|
||||||
|
{
|
||||||
|
defaultValue = this.options[i]
|
||||||
|
args.dropdownData[args.componentIndex] = defaultValue?.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(e)
|
||||||
|
{
|
||||||
|
console.log(`Error getting default value for dropdown [${this.dropdownName}] from local storage`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// if there wasn't a value selected, but there is a default from the backend, then use it. //
|
||||||
|
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
if (defaultValue == null && this.dropdownDefaultValue != null)
|
||||||
|
{
|
||||||
|
for (let i = 0; i < this.options.length; i++)
|
||||||
|
{
|
||||||
|
if(this.options[i].id == this.dropdownDefaultValue)
|
||||||
|
{
|
||||||
|
defaultValue = this.options[i];
|
||||||
|
args.dropdownData[args.componentIndex] = defaultValue?.id;
|
||||||
|
|
||||||
|
if (args.widgetProps.storeDropdownSelections)
|
||||||
|
{
|
||||||
|
localStorage.setItem(localStorageKey, JSON.stringify(defaultValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onChangeCallback(label, defaultValue);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/////////////////////////////////////////////////////////////////////////////
|
||||||
|
// if there's a 'label for null value' (and no default from the backend), //
|
||||||
|
// then add that as an option (and select it if nothing else was selected) //
|
||||||
|
/////////////////////////////////////////////////////////////////////////////
|
||||||
|
let options = this.options;
|
||||||
|
if (this.dropdownMetaData.labelForNullValue && !this.dropdownDefaultValue)
|
||||||
|
{
|
||||||
|
const nullOption = {id: null as string, label: this.dropdownMetaData.labelForNullValue};
|
||||||
|
options = [nullOption, ...this.options];
|
||||||
|
|
||||||
|
if (!defaultValue)
|
||||||
|
{
|
||||||
|
defaultValue = nullOption;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box my={2} sx={{float: "right"}}>
|
<Box my={2} sx={{float: "right"}}>
|
||||||
<DropdownMenu
|
<WidgetDropdownMenu
|
||||||
name={this.dropdownName}
|
name={this.dropdownName}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
sx={{width: 200, marginLeft: "15px"}}
|
sx={{marginLeft: "1rem"}}
|
||||||
label={`Select ${this.label}`}
|
label={label}
|
||||||
dropdownOptions={this.options}
|
startIcon={this.dropdownMetaData.startIconName}
|
||||||
|
allowBackAndForth={this.dropdownMetaData.allowBackAndForth}
|
||||||
|
backAndForthInverted={this.dropdownMetaData.backAndForthInverted}
|
||||||
|
disableClearable={this.dropdownMetaData.disableClearable}
|
||||||
|
dropdownOptions={options}
|
||||||
onChangeCallback={this.onChangeCallback}
|
onChangeCallback={this.onChangeCallback}
|
||||||
|
width={this.dropdownMetaData.width ?? 225}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@ -332,7 +402,12 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
|
|||||||
props.widgetData.dropdownDataList?.map((dropdownData: any, index: number) =>
|
props.widgetData.dropdownDataList?.map((dropdownData: any, index: number) =>
|
||||||
{
|
{
|
||||||
// console.log(`${props.widgetMetaData.name} building a Dropdown, data is: ${dropdownData}`);
|
// console.log(`${props.widgetMetaData.name} building a Dropdown, data is: ${dropdownData}`);
|
||||||
updatedStateLabelComponentsRight.push(new Dropdown(props.widgetData.dropdownLabelList[index], dropdownData, props.widgetData.dropdownNameList[index], handleDataChange));
|
let defaultValue = null;
|
||||||
|
if(props.widgetData.dropdownDefaultValueList && props.widgetData.dropdownDefaultValueList.length >= index)
|
||||||
|
{
|
||||||
|
defaultValue = props.widgetData.dropdownDefaultValueList[index];
|
||||||
|
}
|
||||||
|
updatedStateLabelComponentsRight.push(new Dropdown(props.widgetData.dropdownLabelList[index], props.widgetMetaData.dropdowns[index], dropdownData, defaultValue, props.widgetData.dropdownNameList[index], handleDataChange));
|
||||||
});
|
});
|
||||||
setLabelComponentsRight(updatedStateLabelComponentsRight);
|
setLabelComponentsRight(updatedStateLabelComponentsRight);
|
||||||
}
|
}
|
||||||
@ -460,16 +535,16 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
|
|||||||
// first look for a label in the widget data, which would override that in the metadata //
|
// first look for a label in the widget data, which would override that in the metadata //
|
||||||
// note - previously this had a ?: and one was pl={2}, the other was pl={3}... //
|
// note - previously this had a ?: and one was pl={2}, the other was pl={3}... //
|
||||||
//////////////////////////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////////////////////////
|
||||||
const labelToUse = props.widgetData?.label ?? props.widgetMetaData?.label
|
const labelToUse = props.widgetData?.label ?? props.widgetMetaData?.label;
|
||||||
let labelElement = (
|
let labelElement = (
|
||||||
<Typography sx={{cursor: "default", pl: "auto", pt: props.widgetMetaData.type == "parentWidget" ? "1rem" : "auto", fontWeight: 600}} variant="h6" display="inline">
|
<Typography sx={{cursor: "default", pl: "auto", pt: props.widgetMetaData.type == "parentWidget" ? "1rem" : "auto", fontWeight: 600}} variant="h6" display="inline">
|
||||||
{labelToUse}
|
{labelToUse}
|
||||||
</Typography>
|
</Typography>
|
||||||
);
|
);
|
||||||
|
|
||||||
if(props.widgetMetaData.tooltip)
|
if (props.widgetMetaData.tooltip)
|
||||||
{
|
{
|
||||||
labelElement = <Tooltip title={props.widgetMetaData.tooltip} arrow={false} followCursor={true} placement="bottom-start">{labelElement}</Tooltip>
|
labelElement = <Tooltip title={props.widgetMetaData.tooltip} arrow={false} followCursor={true} placement="bottom-start">{labelElement}</Tooltip>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorLoading = props.widgetData?.errorLoading !== undefined && props.widgetData?.errorLoading === true;
|
const errorLoading = props.widgetData?.errorLoading !== undefined && props.widgetData?.errorLoading === true;
|
||||||
@ -578,7 +653,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
|
|||||||
}
|
}
|
||||||
</Box>;
|
</Box>;
|
||||||
|
|
||||||
const padding = props.omitPadding? "auto" : "24px 16px";
|
const padding = props.omitPadding ? "auto" : "24px 16px";
|
||||||
return props.widgetMetaData?.isCard
|
return props.widgetMetaData?.isCard
|
||||||
? <Card sx={{marginTop: props.widgetMetaData?.icon ? 2 : 0, width: "100%", p: padding}} className={fullScreenWidgetClassName}>
|
? <Card sx={{marginTop: props.widgetMetaData?.icon ? 2 : 0, width: "100%", p: padding}} className={fullScreenWidgetClassName}>
|
||||||
{widgetContent}
|
{widgetContent}
|
||||||
|
@ -1,179 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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 {Collapse, Theme} from "@mui/material";
|
|
||||||
import Autocomplete from "@mui/material/Autocomplete";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import TextField from "@mui/material/TextField";
|
|
||||||
import {SxProps} from "@mui/system";
|
|
||||||
import {Field, Form, Formik} from "formik";
|
|
||||||
import React, {useState} from "react";
|
|
||||||
import MDInput from "qqq/components/legacy/MDInput";
|
|
||||||
import FilterUtils from "qqq/utils/qqq/FilterUtils";
|
|
||||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
|
||||||
|
|
||||||
|
|
||||||
export interface DropdownOption
|
|
||||||
{
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/////////////////////////
|
|
||||||
// inputs and defaults //
|
|
||||||
/////////////////////////
|
|
||||||
interface Props
|
|
||||||
{
|
|
||||||
name: string;
|
|
||||||
defaultValue?: any;
|
|
||||||
label?: string;
|
|
||||||
dropdownOptions?: DropdownOption[];
|
|
||||||
onChangeCallback?: (dropdownLabel: string, data: any) => void;
|
|
||||||
sx?: SxProps<Theme>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StartAndEndDate
|
|
||||||
{
|
|
||||||
startDate?: string,
|
|
||||||
endDate?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCustomTimeValuesFromDefaultValue(defaultValue: any): StartAndEndDate
|
|
||||||
{
|
|
||||||
const customTimeValues: StartAndEndDate = {};
|
|
||||||
if(defaultValue && defaultValue.id)
|
|
||||||
{
|
|
||||||
var parts = defaultValue.id.split(",");
|
|
||||||
if(parts.length >= 2)
|
|
||||||
{
|
|
||||||
customTimeValues["startDate"] = ValueUtils.formatDateTimeValueForForm(parts[1]);
|
|
||||||
}
|
|
||||||
if(parts.length >= 3)
|
|
||||||
{
|
|
||||||
customTimeValues["endDate"] = ValueUtils.formatDateTimeValueForForm(parts[2]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (customTimeValues);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeBackendValuesFromFrontendValues(frontendDefaultValues: StartAndEndDate): StartAndEndDate
|
|
||||||
{
|
|
||||||
const backendTimeValues: StartAndEndDate = {};
|
|
||||||
if(frontendDefaultValues && frontendDefaultValues.startDate)
|
|
||||||
{
|
|
||||||
backendTimeValues.startDate = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(frontendDefaultValues.startDate);
|
|
||||||
}
|
|
||||||
if(frontendDefaultValues && frontendDefaultValues.endDate)
|
|
||||||
{
|
|
||||||
backendTimeValues.endDate = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(frontendDefaultValues.endDate);
|
|
||||||
}
|
|
||||||
return (backendTimeValues);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenu({name, defaultValue, label, dropdownOptions, onChangeCallback, sx}: Props): JSX.Element
|
|
||||||
{
|
|
||||||
const [customTimesVisible, setCustomTimesVisible] = useState(defaultValue && defaultValue.id && defaultValue.id.startsWith("custom,"));
|
|
||||||
const [customTimeValuesFrontend, setCustomTimeValuesFrontend] = useState(parseCustomTimeValuesFromDefaultValue(defaultValue) as StartAndEndDate);
|
|
||||||
const [customTimeValuesBackend, setCustomTimeValuesBackend] = useState(makeBackendValuesFromFrontendValues(customTimeValuesFrontend) as StartAndEndDate);
|
|
||||||
const [debounceTimeout, setDebounceTimeout] = useState(null as any);
|
|
||||||
|
|
||||||
const handleOnChange = (event: any, newValue: any, reason: string) =>
|
|
||||||
{
|
|
||||||
const isTimeframeCustom = name == "timeframe" && newValue && newValue.id == "custom"
|
|
||||||
setCustomTimesVisible(isTimeframeCustom);
|
|
||||||
|
|
||||||
if(isTimeframeCustom)
|
|
||||||
{
|
|
||||||
callOnChangeCallbackIfCustomTimeframeHasDateValues();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
onChangeCallback(label, newValue);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const callOnChangeCallbackIfCustomTimeframeHasDateValues = () =>
|
|
||||||
{
|
|
||||||
if(customTimeValuesBackend["startDate"] && customTimeValuesBackend["endDate"])
|
|
||||||
{
|
|
||||||
onChangeCallback(label, {id: `custom,${customTimeValuesBackend["startDate"]},${customTimeValuesBackend["endDate"]}`, label: "Custom"});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let customTimes = <></>;
|
|
||||||
if (name == "timeframe")
|
|
||||||
{
|
|
||||||
const handleSubmit = async (values: any, actions: any) =>
|
|
||||||
{
|
|
||||||
};
|
|
||||||
|
|
||||||
const dateChanged = (fieldName: "startDate" | "endDate", event: any) =>
|
|
||||||
{
|
|
||||||
customTimeValuesFrontend[fieldName] = event.target.value;
|
|
||||||
customTimeValuesBackend[fieldName] = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(event.target.value);
|
|
||||||
|
|
||||||
clearTimeout(debounceTimeout);
|
|
||||||
const newDebounceTimeout = setTimeout(() =>
|
|
||||||
{
|
|
||||||
callOnChangeCallbackIfCustomTimeframeHasDateValues();
|
|
||||||
}, 500);
|
|
||||||
setDebounceTimeout(newDebounceTimeout);
|
|
||||||
};
|
|
||||||
|
|
||||||
customTimes = <Box sx={{display: "inline-block", position: "relative", top: "-7px"}}>
|
|
||||||
<Collapse orientation="horizontal" in={customTimesVisible}>
|
|
||||||
<Formik initialValues={customTimeValuesFrontend} onSubmit={handleSubmit}>
|
|
||||||
{({}) => (
|
|
||||||
<Form id="timeframe-form" autoComplete="off">
|
|
||||||
<Field name="startDate" type="datetime-local" as={MDInput} variant="standard" label="Custom Timeframe Start" InputLabelProps={{shrink: true}} InputProps={{size: "small"}} sx={{ml: 2, width: 198}} onChange={(event: any) => dateChanged("startDate", event)} />
|
|
||||||
<Field name="endDate" type="datetime-local" as={MDInput} variant="standard" label="Custom Timeframe End" InputLabelProps={{shrink: true}} InputProps={{size: "small"}} sx={{ml: 2, width: 198}} onChange={(event: any) => dateChanged("endDate", event)} />
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
</Collapse>
|
|
||||||
</Box>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
dropdownOptions ? (
|
|
||||||
<span style={{whiteSpace: "nowrap", display: "flex"}} className="dashboardDropdownMenu">
|
|
||||||
<Autocomplete
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
size="small"
|
|
||||||
disablePortal
|
|
||||||
id={`${label}-combo-box`}
|
|
||||||
options={dropdownOptions}
|
|
||||||
sx={{...sx, cursor: "pointer", display: "inline-block"}}
|
|
||||||
onChange={handleOnChange}
|
|
||||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
|
||||||
renderInput={(params: any) => <TextField {...params} label={label} />}
|
|
||||||
renderOption={(props, option: DropdownOption) => (
|
|
||||||
<li {...props} style={{whiteSpace: "normal"}}>{option.label}</li>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{customTimes}
|
|
||||||
</span>
|
|
||||||
) : null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DropdownMenu;
|
|
333
src/qqq/components/widgets/components/WidgetDropdownMenu.tsx
Normal file
333
src/qqq/components/widgets/components/WidgetDropdownMenu.tsx
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
/*
|
||||||
|
* 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 {Collapse, Theme, InputAdornment} from "@mui/material";
|
||||||
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Icon from "@mui/material/Icon";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import {SxProps} from "@mui/system";
|
||||||
|
import {Field, Form, Formik} from "formik";
|
||||||
|
import React, {useState} from "react";
|
||||||
|
import colors from "qqq/assets/theme/base/colors";
|
||||||
|
import MDInput from "qqq/components/legacy/MDInput";
|
||||||
|
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||||
|
|
||||||
|
|
||||||
|
export interface DropdownOption
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/////////////////////////
|
||||||
|
// inputs and defaults //
|
||||||
|
/////////////////////////
|
||||||
|
interface Props
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
defaultValue?: any;
|
||||||
|
label?: string;
|
||||||
|
startIcon?: string;
|
||||||
|
width?: number;
|
||||||
|
disableClearable?: boolean;
|
||||||
|
allowBackAndForth?: boolean;
|
||||||
|
backAndForthInverted?: boolean;
|
||||||
|
dropdownOptions?: DropdownOption[];
|
||||||
|
onChangeCallback?: (dropdownLabel: string, data: any) => void;
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StartAndEndDate
|
||||||
|
{
|
||||||
|
startDate?: string,
|
||||||
|
endDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCustomTimeValuesFromDefaultValue(defaultValue: any): StartAndEndDate
|
||||||
|
{
|
||||||
|
const customTimeValues: StartAndEndDate = {};
|
||||||
|
if (defaultValue && defaultValue.id)
|
||||||
|
{
|
||||||
|
var parts = defaultValue.id.split(",");
|
||||||
|
if (parts.length >= 2)
|
||||||
|
{
|
||||||
|
customTimeValues["startDate"] = ValueUtils.formatDateTimeValueForForm(parts[1]);
|
||||||
|
}
|
||||||
|
if (parts.length >= 3)
|
||||||
|
{
|
||||||
|
customTimeValues["endDate"] = ValueUtils.formatDateTimeValueForForm(parts[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (customTimeValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBackendValuesFromFrontendValues(frontendDefaultValues: StartAndEndDate): StartAndEndDate
|
||||||
|
{
|
||||||
|
const backendTimeValues: StartAndEndDate = {};
|
||||||
|
if (frontendDefaultValues && frontendDefaultValues.startDate)
|
||||||
|
{
|
||||||
|
backendTimeValues.startDate = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(frontendDefaultValues.startDate);
|
||||||
|
}
|
||||||
|
if (frontendDefaultValues && frontendDefaultValues.endDate)
|
||||||
|
{
|
||||||
|
backendTimeValues.endDate = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(frontendDefaultValues.endDate);
|
||||||
|
}
|
||||||
|
return (backendTimeValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetDropdownMenu({name, defaultValue, label, startIcon, width, disableClearable, allowBackAndForth, backAndForthInverted, dropdownOptions, onChangeCallback, sx}: Props): JSX.Element
|
||||||
|
{
|
||||||
|
const [customTimesVisible, setCustomTimesVisible] = useState(defaultValue && defaultValue.id && defaultValue.id.startsWith("custom,"));
|
||||||
|
const [customTimeValuesFrontend, setCustomTimeValuesFrontend] = useState(parseCustomTimeValuesFromDefaultValue(defaultValue) as StartAndEndDate);
|
||||||
|
const [customTimeValuesBackend, setCustomTimeValuesBackend] = useState(makeBackendValuesFromFrontendValues(customTimeValuesFrontend) as StartAndEndDate);
|
||||||
|
const [debounceTimeout, setDebounceTimeout] = useState(null as any);
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [value, setValue] = useState(defaultValue);
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
|
||||||
|
const [backDisabled, setBackDisabled] = useState(false);
|
||||||
|
const [forthDisabled, setForthDisabled] = useState(false);
|
||||||
|
|
||||||
|
const doForceOpen = (event: React.MouseEvent<HTMLDivElement>) =>
|
||||||
|
{
|
||||||
|
setIsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSelectedIndex(value: DropdownOption)
|
||||||
|
{
|
||||||
|
let currentIndex = null;
|
||||||
|
for (let i = 0; i < dropdownOptions.length; i++)
|
||||||
|
{
|
||||||
|
if (value && dropdownOptions[i].id == value.id)
|
||||||
|
{
|
||||||
|
currentIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return currentIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateBackAndForth = (event: React.MouseEvent, direction: -1 | 1) =>
|
||||||
|
{
|
||||||
|
event.stopPropagation();
|
||||||
|
let currentIndex = getSelectedIndex(value);
|
||||||
|
|
||||||
|
if (currentIndex == null)
|
||||||
|
{
|
||||||
|
console.log("No current value.... TODO");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIndex == 0 && direction == -1)
|
||||||
|
{
|
||||||
|
console.log("Can't go -1");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIndex == dropdownOptions.length - 1 && direction == 1)
|
||||||
|
{
|
||||||
|
console.log("Can't go +1");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOnChange(event, dropdownOptions[currentIndex + direction], "navigatedBackAndForth");
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleOnChange = (event: any, newValue: any, reason: string) =>
|
||||||
|
{
|
||||||
|
setValue(newValue);
|
||||||
|
|
||||||
|
const isTimeframeCustom = name == "timeframe" && newValue && newValue.id == "custom";
|
||||||
|
setCustomTimesVisible(isTimeframeCustom);
|
||||||
|
|
||||||
|
if (isTimeframeCustom)
|
||||||
|
{
|
||||||
|
callOnChangeCallbackIfCustomTimeframeHasDateValues();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
onChangeCallback(label, newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentIndex = getSelectedIndex(value);
|
||||||
|
if(currentIndex == 0)
|
||||||
|
{
|
||||||
|
backAndForthInverted ? setForthDisabled(true) : setBackDisabled(true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
backAndForthInverted ? setForthDisabled(false) : setBackDisabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIndex == dropdownOptions.length - 1)
|
||||||
|
{
|
||||||
|
backAndForthInverted ? setBackDisabled(true) : setForthDisabled(true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
backAndForthInverted ? setBackDisabled(false) : setForthDisabled(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnInputChange = (event: any, newValue: any, reason: string) =>
|
||||||
|
{
|
||||||
|
setInputValue(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const callOnChangeCallbackIfCustomTimeframeHasDateValues = () =>
|
||||||
|
{
|
||||||
|
if (customTimeValuesBackend["startDate"] && customTimeValuesBackend["endDate"])
|
||||||
|
{
|
||||||
|
onChangeCallback(label, {id: `custom,${customTimeValuesBackend["startDate"]},${customTimeValuesBackend["endDate"]}`, label: "Custom"});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let customTimes = <></>;
|
||||||
|
if (name == "timeframe")
|
||||||
|
{
|
||||||
|
const handleSubmit = async (values: any, actions: any) =>
|
||||||
|
{
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateChanged = (fieldName: "startDate" | "endDate", event: any) =>
|
||||||
|
{
|
||||||
|
customTimeValuesFrontend[fieldName] = event.target.value;
|
||||||
|
customTimeValuesBackend[fieldName] = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(event.target.value);
|
||||||
|
|
||||||
|
clearTimeout(debounceTimeout);
|
||||||
|
const newDebounceTimeout = setTimeout(() =>
|
||||||
|
{
|
||||||
|
callOnChangeCallbackIfCustomTimeframeHasDateValues();
|
||||||
|
}, 500);
|
||||||
|
setDebounceTimeout(newDebounceTimeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
customTimes = <Box sx={{display: "inline-block", position: "relative", top: "-7px"}}>
|
||||||
|
<Collapse orientation="horizontal" in={customTimesVisible}>
|
||||||
|
<Formik initialValues={customTimeValuesFrontend} onSubmit={handleSubmit}>
|
||||||
|
{({}) => (
|
||||||
|
<Form id="timeframe-form" autoComplete="off">
|
||||||
|
<Field name="startDate" type="datetime-local" as={MDInput} variant="standard" label="Custom Timeframe Start" InputLabelProps={{shrink: true}} InputProps={{size: "small"}} sx={{ml: 2, width: 198}} onChange={(event: any) => dateChanged("startDate", event)} />
|
||||||
|
<Field name="endDate" type="datetime-local" as={MDInput} variant="standard" label="Custom Timeframe End" InputLabelProps={{shrink: true}} InputProps={{size: "small"}} sx={{ml: 2, width: 198}} onChange={(event: any) => dateChanged("endDate", event)} />
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</Collapse>
|
||||||
|
</Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAdornment = startIcon ? <Icon sx={{fontSize: "1.25rem!important", color: colors.gray.main, paddingLeft: allowBackAndForth ? "auto" : "0.25rem", width: allowBackAndForth ? "1.5rem" : "1.75rem"}}>{startIcon}</Icon> : null;
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// we tried this end-adornment, for a different style of down-arrow - but by using it, we then messed something else up (i forget what), so... not used right now //
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
const endAdornment = <InputAdornment position="end" sx={{position: "absolute", right: allowBackAndForth ? "-0.5rem" : "0.5rem"}}><Icon sx={{fontSize: "1.75rem!important", color: colors.gray.main}}>keyboard_arrow_down</Icon></InputAdornment>;
|
||||||
|
|
||||||
|
const fontSize = "1rem";
|
||||||
|
let optionPaddingLeftRems = 0.75;
|
||||||
|
if(startIcon)
|
||||||
|
{
|
||||||
|
optionPaddingLeftRems += allowBackAndForth ? 1.5 : 1.75
|
||||||
|
}
|
||||||
|
if(allowBackAndForth)
|
||||||
|
{
|
||||||
|
optionPaddingLeftRems += 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
dropdownOptions ? (
|
||||||
|
<Box sx={{whiteSpace: "nowrap", display: "flex",
|
||||||
|
"& .MuiPopperUnstyled-root": {
|
||||||
|
border: `1px solid ${colors.grayLines.main}`,
|
||||||
|
borderTop: "none",
|
||||||
|
borderRadius: "0 0 0.75rem 0.75rem",
|
||||||
|
padding: 0,
|
||||||
|
}, "& .MuiPaper-rounded": {
|
||||||
|
borderRadius: "0 0 0.75rem 0.75rem",
|
||||||
|
}
|
||||||
|
}} className="dashboardDropdownMenu">
|
||||||
|
<Autocomplete
|
||||||
|
id={`${label}-combo-box`}
|
||||||
|
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
value={value}
|
||||||
|
onChange={handleOnChange}
|
||||||
|
inputValue={inputValue}
|
||||||
|
onInputChange={handleOnInputChange}
|
||||||
|
|
||||||
|
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||||
|
|
||||||
|
open={isOpen}
|
||||||
|
onOpen={() => setIsOpen(true)}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
|
||||||
|
size="small"
|
||||||
|
disablePortal
|
||||||
|
disableClearable={disableClearable}
|
||||||
|
options={dropdownOptions}
|
||||||
|
sx={{
|
||||||
|
...sx,
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-block",
|
||||||
|
"& .MuiOutlinedInput-notchedOutline": {
|
||||||
|
border: "none"
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
renderInput={(params: any) =>
|
||||||
|
<>
|
||||||
|
<Box sx={{width: `${width}px`, background: "white", borderRadius: isOpen ? "0.75rem 0.75rem 0 0" : "0.75rem", border: `1px solid ${colors.grayLines.main}`, "& *": {cursor: "pointer"}}} display="flex" alignItems="center" onClick={(event) => doForceOpen(event)}>
|
||||||
|
{allowBackAndForth && <IconButton onClick={(event) => navigateBackAndForth(event, backAndForthInverted ? 1 : -1)} disabled={backDisabled}><Icon>navigate_before</Icon></IconButton>}
|
||||||
|
<TextField {...params} placeholder={label} sx={{
|
||||||
|
"& .MuiInputBase-input": {
|
||||||
|
fontSize: fontSize
|
||||||
|
}
|
||||||
|
}} InputProps={{...params.InputProps, startAdornment: startAdornment/*, endAdornment: endAdornment*/}}
|
||||||
|
/>
|
||||||
|
{allowBackAndForth && <IconButton onClick={(event) => navigateBackAndForth(event, backAndForthInverted ? -1 : 1)} disabled={forthDisabled}><Icon>navigate_next</Icon></IconButton>}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
renderOption={(props, option: DropdownOption) => (
|
||||||
|
<li {...props} style={{whiteSpace: "normal", fontSize: fontSize, paddingLeft: `${optionPaddingLeftRems}rem`}}>{option.label}</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
noOptionsText={<Box fontSize={fontSize}>No options found</Box>}
|
||||||
|
|
||||||
|
slotProps={{
|
||||||
|
popper: {
|
||||||
|
sx: {
|
||||||
|
width: `${width}px!important`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{customTimes}
|
||||||
|
</Box>
|
||||||
|
) : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WidgetDropdownMenu;
|
Reference in New Issue
Block a user