/* * 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 {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData"; 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 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 WidgetDropdownMenu, {DropdownOption} from "qqq/components/widgets/components/WidgetDropdownMenu"; export interface WidgetData { label?: string; dropdownLabelList?: string[]; dropdownNameList?: string[]; dropdownDataList?: { id: string, label: string }[][]; dropdownDefaultValueList?: string[]; dropdownNeedsSelectedText?: string; hasPermission?: boolean; errorLoading?: boolean; [other: string]: any; } interface Props { labelAdditionalComponentsLeft: LabelComponent[]; labelAdditionalElementsLeft: JSX.Element[]; labelAdditionalComponentsRight: LabelComponent[]; widgetMetaData?: QWidgetMetaData; widgetData?: WidgetData; children: JSX.Element; reloadWidgetCallback?: (params: string) => void; showReloadControl: boolean; isChild?: boolean; footerHTML?: string; storeDropdownSelections?: boolean; omitPadding: boolean; } Widget.defaultProps = { isChild: false, showReloadControl: true, widgetMetaData: {}, widgetData: {}, labelAdditionalComponentsLeft: [], labelAdditionalElementsLeft: [], labelAdditionalComponentsRight: [], omitPadding: false, }; interface LabelComponentRenderArgs { navigate: NavigateFunction; widgetProps: Props; dropdownData: any[]; componentIndex: number; reloadFunction: () => void; } export class LabelComponent { render = (args: LabelComponentRenderArgs): JSX.Element => { return (
Unsupported component type
); }; } /******************************************************************************* ** *******************************************************************************/ export class HeaderIcon extends LabelComponent { iconName: string; iconPath: string; color: string; coloredBG: boolean; iconColor: string; bgColor: string; constructor(iconName: string, iconPath: string, color: string, coloredBG: boolean = true) { super(); this.iconName = iconName; this.iconPath = iconPath; this.color = color; this.coloredBG = coloredBG; this.iconColor = this.coloredBG ? "#FFFFFF" : this.color; this.bgColor = this.coloredBG ? this.color : "none"; } render = (args: LabelComponentRenderArgs): JSX.Element => { const styles = { width: "1.75rem", height: "1.75rem", color: this.iconColor, backgroundColor: this.bgColor, borderRadius: "0.25rem" }; if (this.iconPath) { return (); } else { return ({this.iconName}); } }; } /******************************************************************************* ** *******************************************************************************/ export class AddNewRecordButton extends LabelComponent { table: QTableMetaData; label: string; defaultValues: any; disabledFields: any; constructor(table: QTableMetaData, defaultValues: any, label: string = "Add new", disabledFields: any = defaultValues) { super(); this.table = table; this.label = label; this.defaultValues = defaultValues; this.disabledFields = disabledFields; } openEditForm = (navigate: any, table: QTableMetaData, id: any = null, defaultValues: any, disabledFields: any) => { navigate(`#/createChild=${table.name}/defaultValues=${JSON.stringify(defaultValues)}/disabledFields=${JSON.stringify(disabledFields)}`); }; render = (args: LabelComponentRenderArgs): JSX.Element => { return ( ); }; } /******************************************************************************* ** *******************************************************************************/ export class Dropdown extends LabelComponent { label: string; dropdownMetaData: any; options: DropdownOption[]; dropdownDefaultValue?: string; 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 // // 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 ( ); }; } /******************************************************************************* ** *******************************************************************************/ export class ReloadControl extends LabelComponent { callback: () => void; constructor(callback: () => void) { super(); this.callback = callback; } render = (args: LabelComponentRenderArgs): JSX.Element => { return ( ); }; } export const WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT = "qqq.widgets.dropdownData"; /******************************************************************************* ** *******************************************************************************/ function Widget(props: React.PropsWithChildren): JSX.Element { const navigate = useNavigate(); const [dropdownData, setDropdownData] = useState([]); const [fullScreenWidgetClassName, setFullScreenWidgetClassName] = useState(""); const [reloading, setReloading] = useState(false); const [dropdownDataJSON, setDropdownDataJSON] = useState(""); const [labelComponentsLeft, setLabelComponentsLeft] = useState([] as LabelComponent[]); const [labelComponentsRight, setLabelComponentsRight] = useState([] as LabelComponent[]); function renderComponent(component: LabelComponent, componentIndex: number) { return component.render({navigate: navigate, widgetProps: props, dropdownData: dropdownData, componentIndex: componentIndex, reloadFunction: doReload}); } useEffect(() => { //////////////////////////////////////////////////////////////////////////////// // for initial render, put left-components from props into the state variable // // plus others we can infer from other props // //////////////////////////////////////////////////////////////////////////////// const stateLabelComponentsLeft: LabelComponent[] = []; if (props.reloadWidgetCallback && props.widgetData && props.showReloadControl && props.widgetMetaData.showReloadButton) { stateLabelComponentsLeft.push(new ReloadControl(doReload)); } if (props.labelAdditionalComponentsLeft) { props.labelAdditionalComponentsLeft.map((component) => stateLabelComponentsLeft.push(component)); } setLabelComponentsLeft(stateLabelComponentsLeft); }, []); useEffect(() => { ///////////////////////////////////////////////////////////////////////////////// // for initial render, put right-components from props into the state variable // ///////////////////////////////////////////////////////////////////////////////// const stateLabelComponentsRight = [] as LabelComponent[]; // console.log(`${props.widgetMetaData.name} init'ing right-components`); if (props.labelAdditionalComponentsRight) { props.labelAdditionalComponentsRight.map((component) => stateLabelComponentsRight.push(component)); } setLabelComponentsRight(stateLabelComponentsRight); }, []); ////////////////////////////////////////////////////////////////////////////////////////////////////////// // if we have widgetData, and it has a dropdown list, capture that in a state variable, if it's changed // ////////////////////////////////////////////////////////////////////////////////////////////////////////// if (props.widgetData && props.widgetData.dropdownDataList) { const currentDropdownDataJSON = JSON.stringify(props.widgetData.dropdownDataList); if (currentDropdownDataJSON !== dropdownDataJSON) { // console.log(`${props.widgetMetaData.name} we have (new) dropdown data!!: ${currentDropdownDataJSON}`); setDropdownDataJSON(currentDropdownDataJSON); } } useEffect(() => { /////////////////////////////////////////////////////////////////////////////////// // if we've seen a change in the dropdown data, then update the right-components // /////////////////////////////////////////////////////////////////////////////////// // console.log(`${props.widgetMetaData.name} in useEffect post dropdownData change`); if (props.widgetData && props.widgetData.dropdownDataList) { const updatedStateLabelComponentsRight = JSON.parse(JSON.stringify(labelComponentsRight)) as LabelComponent[]; props.widgetData.dropdownDataList?.map((dropdownData: any, index: number) => { // console.log(`${props.widgetMetaData.name} building a Dropdown, data is: ${dropdownData}`); 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); } }, [dropdownDataJSON]); const doReload = () => { setReloading(true); reloadWidget(dropdownData); }; useEffect(() => { setReloading(false); }, [props.widgetData]); function handleDataChange(dropdownLabel: string, changedData: any) { if (dropdownData) { /////////////////////////////////////////// // find the index base on selected label // /////////////////////////////////////////// const tableName = dropdownLabel.replace("Select ", ""); let dropdownName = ""; let index = -1; for (let i = 0; i < props.widgetData.dropdownLabelList.length; i++) { if (tableName === props.widgetData.dropdownLabelList[i]) { index = i; dropdownName = props.widgetData.dropdownNameList[i]; break; } } if (index < 0) { throw (`Could not find table name for label ${tableName}`); } dropdownData[index] = (changedData) ? changedData.id : null; setDropdownData(dropdownData); ///////////////////////////////////////////////// // if should store in local storage, do so now // // or remove if dropdown was cleared out // ///////////////////////////////////////////////// if (props.storeDropdownSelections) { if (changedData?.id) { localStorage.setItem(`${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${props.widgetMetaData.name}.${dropdownName}`, JSON.stringify(changedData)); } else { localStorage.removeItem(`${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${props.widgetMetaData.name}.${dropdownName}`); } } reloadWidget(dropdownData); } } const reloadWidget = (dropdownData: any[]) => { let params = ""; for (let i = 0; i < dropdownData.length; i++) { if (i > 0) { params += "&"; } params += `${props.widgetData.dropdownNameList[i]}=`; if (dropdownData[i]) { params += `${dropdownData[i]}`; } } if (props.reloadWidgetCallback) { props.reloadWidgetCallback(params); } else { console.log(`No reload widget callback in ${props.widgetMetaData.label}`); } }; const toggleFullScreenWidget = () => { if (fullScreenWidgetClassName) { setFullScreenWidgetClassName(""); } else { setFullScreenWidgetClassName("fullScreenWidget"); } }; const hasPermission = props.widgetData?.hasPermission === undefined || props.widgetData?.hasPermission === true; const isSet = (v: any): boolean => { return (v !== null && v !== undefined); }; ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // to avoid taking up the space of the Box with the label and icon and label-components (since it has a height), only output that box if we need any of the components // ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// let needLabelBox = false; if (hasPermission) { needLabelBox ||= (labelComponentsLeft && labelComponentsLeft.length > 0); needLabelBox ||= (props.labelAdditionalElementsLeft && props.labelAdditionalElementsLeft.length > 0); needLabelBox ||= (labelComponentsRight && labelComponentsRight.length > 0); needLabelBox ||= isSet(props.widgetMetaData?.icon); needLabelBox ||= isSet(props.widgetData?.label); needLabelBox ||= isSet(props.widgetMetaData?.label); } ////////////////////////////////////////////////////////////////////////////////////////// // 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; let labelElement = ( {labelToUse} ); if (props.widgetMetaData.tooltip) { labelElement = {labelElement}; } const errorLoading = props.widgetData?.errorLoading !== undefined && props.widgetData?.errorLoading === true; const widgetContent = { needLabelBox && { hasPermission ? props.widgetMetaData?.icon && ( {props.widgetMetaData.icon} ) : ( lock ) } { hasPermission && labelToUse && (labelElement) } { hasPermission && ( labelComponentsLeft.map((component, i) => { return ({renderComponent(component, i)}); }) ) } {props.labelAdditionalElementsLeft} { hasPermission && ( labelComponentsRight.map((component, i) => { return ({renderComponent(component, i)}); }) ) } } { /////////////////////////////////////////////////////////////////// // turning this off... for now. maybe make a property in future // /////////////////////////////////////////////////////////////////// /* props.widgetMetaData?.isCard && (reloading ? : ) */ } { errorLoading ? ( error An error occurred loading widget content. ) : ( hasPermission && props.widgetData?.dropdownNeedsSelectedText ? ( {props.widgetData?.dropdownNeedsSelectedText} ) : ( hasPermission ? ( props.children ) : ( You do not have permission to view this data. ) ) ) } { !errorLoading && props?.footerHTML && ( {parse(props.footerHTML)} ) } ; const padding = props.omitPadding ? "auto" : "24px 16px"; return props.widgetMetaData?.isCard ? {widgetContent} : {widgetContent}; } export default Widget;