diff --git a/src/qqq/components/widgets/Widget.tsx b/src/qqq/components/widgets/Widget.tsx index 4894211..59f5258 100644 --- a/src/qqq/components/widgets/Widget.tsx +++ b/src/qqq/components/widgets/Widget.tsx @@ -25,14 +25,13 @@ import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; import Icon from "@mui/material/Icon"; -import LinearProgress from "@mui/material/LinearProgress"; import Tooltip from "@mui/material/Tooltip/Tooltip"; import Typography from "@mui/material/Typography"; import parse from "html-react-parser"; import React, {useEffect, useState} from "react"; import {NavigateFunction, useNavigate} from "react-router-dom"; 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 { @@ -43,6 +42,7 @@ export interface WidgetData id: string, label: string }[][]; + dropdownDefaultValueList?: string[]; dropdownNeedsSelectedText?: string; hasPermission?: boolean; errorLoading?: boolean; @@ -134,15 +134,15 @@ export class HeaderIcon extends LabelComponent borderRadius: "0.25rem" }; - if(this.iconPath) + if (this.iconPath) { - return () + return (); } else { return ({this.iconName}); } - } + }; } @@ -188,41 +188,111 @@ export class AddNewRecordButton extends LabelComponent export class Dropdown extends LabelComponent { label: string; + dropdownMetaData: any; options: DropdownOption[]; + dropdownDefaultValue?: string; dropdownName: string; 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(); this.label = label; + this.dropdownMetaData = dropdownMetaData; this.options = options; + this.dropdownDefaultValue = dropdownDefaultValue; this.dropdownName = dropdownName; this.onChangeCallback = onChangeCallback; } render = (args: LabelComponentRenderArgs): JSX.Element => { + const label = `Select ${this.label}`; let defaultValue = null; const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${args.widgetProps.widgetMetaData.name}.${this.dropdownName}`; if (args.widgetProps.storeDropdownSelections) { - /////////////////////////////////////////////////////////////////////////////////////// - // see if an existing value is stored in local storage, and if so set it in dropdown // - /////////////////////////////////////////////////////////////////////////////////////// - defaultValue = JSON.parse(localStorage.getItem(localStorageKey)); - args.dropdownData[args.componentIndex] = defaultValue?.id; + //////////////////////////////////////////////////////////////////////////////////////////// + // 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 // + // changed since it was stored, we'll instead just find the option by id (or in case that // + // 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 ( - ); @@ -332,7 +402,12 @@ function Widget(props: React.PropsWithChildren): JSX.Element props.widgetData.dropdownDataList?.map((dropdownData: any, index: number) => { // 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); } @@ -460,16 +535,16 @@ function Widget(props: React.PropsWithChildren): JSX.Element // 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}... // ////////////////////////////////////////////////////////////////////////////////////////// - const labelToUse = props.widgetData?.label ?? props.widgetMetaData?.label + const labelToUse = props.widgetData?.label ?? props.widgetMetaData?.label; let labelElement = ( {labelToUse} ); - if(props.widgetMetaData.tooltip) + if (props.widgetMetaData.tooltip) { - labelElement = {labelElement} + labelElement = {labelElement}; } const errorLoading = props.widgetData?.errorLoading !== undefined && props.widgetData?.errorLoading === true; @@ -578,7 +653,7 @@ function Widget(props: React.PropsWithChildren): JSX.Element } ; - const padding = props.omitPadding? "auto" : "24px 16px"; + const padding = props.omitPadding ? "auto" : "24px 16px"; return props.widgetMetaData?.isCard ? {widgetContent} diff --git a/src/qqq/components/widgets/components/DropdownMenu.tsx b/src/qqq/components/widgets/components/DropdownMenu.tsx deleted file mode 100644 index 7266cb7..0000000 --- a/src/qqq/components/widgets/components/DropdownMenu.tsx +++ /dev/null @@ -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 . - */ - -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; -} - -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 = - - - {({}) => ( -
- dateChanged("startDate", event)} /> - dateChanged("endDate", event)} /> - - )} -
-
-
; - } - - return ( - dropdownOptions ? ( - - option.id === value.id} - renderInput={(params: any) => } - renderOption={(props, option: DropdownOption) => ( -
  • {option.label}
  • - )} - /> - {customTimes} -
    - ) : null - ); -} - -export default DropdownMenu; diff --git a/src/qqq/components/widgets/components/WidgetDropdownMenu.tsx b/src/qqq/components/widgets/components/WidgetDropdownMenu.tsx new file mode 100644 index 0000000..743b0a9 --- /dev/null +++ b/src/qqq/components/widgets/components/WidgetDropdownMenu.tsx @@ -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 . + */ + +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; +} + +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) => + { + 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 = + + + {({}) => ( +
    + dateChanged("startDate", event)} /> + dateChanged("endDate", event)} /> + + )} +
    +
    +
    ; + } + + const startAdornment = startIcon ? {startIcon} : 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 = keyboard_arrow_down; + + const fontSize = "1rem"; + let optionPaddingLeftRems = 0.75; + if(startIcon) + { + optionPaddingLeftRems += allowBackAndForth ? 1.5 : 1.75 + } + if(allowBackAndForth) + { + optionPaddingLeftRems += 2.5; + } + + return ( + dropdownOptions ? ( + + 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) => + <> + doForceOpen(event)}> + {allowBackAndForth && navigateBackAndForth(event, backAndForthInverted ? 1 : -1)} disabled={backDisabled}>navigate_before} + + {allowBackAndForth && navigateBackAndForth(event, backAndForthInverted ? -1 : 1)} disabled={forthDisabled}>navigate_next} + + + } + renderOption={(props, option: DropdownOption) => ( +
  • {option.label}
  • + )} + + noOptionsText={No options found} + + slotProps={{ + popper: { + sx: { + width: `${width}px!important` + } + } + }} + /> + {customTimes} +
    + ) : null + ); +} + +export default WidgetDropdownMenu;