/* * 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, {useContext, useEffect, useState} from "react"; import {NavigateFunction, useNavigate} from "react-router-dom"; import QContext from "QContext"; import colors from "qqq/assets/theme/base/colors"; import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent"; import WidgetDropdownMenu, {DropdownOption} from "qqq/components/widgets/components/WidgetDropdownMenu"; import {WidgetUtils} from "qqq/components/widgets/WidgetUtils"; import HtmlUtils from "qqq/utils/HtmlUtils"; 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[]; labelBoxAdditionalSx?: any; 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: [], labelBoxAdditionalSx: {}, 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; role: string; iconColor: string; bgColor: string; constructor(iconName: string, iconPath: string, color: string, role?: string, coloredBG: boolean = true) { super(); this.iconName = iconName; this.iconPath = iconPath; this.color = color; this.role = role; this.coloredBG = coloredBG; this.iconColor = this.coloredBG ? "#FFFFFF" : this.color; this.bgColor = this.coloredBG ? this.color : "none"; } render = (args: LabelComponentRenderArgs): JSX.Element => { const styles: any = { width: "1.75rem", height: "1.75rem", color: this.iconColor, backgroundColor: this.bgColor, borderRadius: "0.25rem" }; if (this.role == "topLeftInsideCard") { styles["order"] = -1; styles["marginRight"] = "0.5rem"; } if (this.iconPath) { return (); } else { return ({this.iconName}); } }; } /******************************************************************************* ** *******************************************************************************/ export class AddNewRecordButton extends LabelComponent { table: QTableMetaData; label: string; defaultValues: any; disabledFields: any; addNewRecordCallback?: () => void; constructor(table: QTableMetaData, defaultValues: any, label: string = "Add new", disabledFields: any = defaultValues, addNewRecordCallback?: () => void) { super(); this.table = table; this.label = label; this.defaultValues = defaultValues; this.disabledFields = disabledFields; this.addNewRecordCallback = addNewRecordCallback; } 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 [reloading, setReloading] = useState(false); const [dropdownDataJSON, setDropdownDataJSON] = useState(""); const [labelComponentsLeft, setLabelComponentsLeft] = useState([] as LabelComponent[]); const [labelComponentsRight, setLabelComponentsRight] = useState([] as LabelComponent[]); //////////////////////////////////////////////////////////////////////////////////////////////////////// // support for using widget (data) label as page header, w/o it disappearing if dropdowns are changed // //////////////////////////////////////////////////////////////////////////////////////////////////////// const [lastSeenLabel, setLastSeenLabel] = useState(""); const [usingLabelAsTitle, setUsingLabelAsTitle] = useState(false); const {helpHelpActive} = useContext(QContext); function renderComponent(component: LabelComponent, componentIndex: number) { if (component && component.render) { return component.render({navigate: navigate, widgetProps: props, dropdownData: dropdownData, componentIndex: componentIndex, reloadFunction: doReload}); } else { console.log("Request to render a null component or component without a render function..."); console.log(JSON.stringify(component)); return (<>); } } 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} initiating 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]; } if (props.widgetData?.dropdownLabelList && props.widgetData?.dropdownLabelList[index] && props.widgetMetaData?.dropdowns && props.widgetMetaData?.dropdowns[index] && props.widgetData?.dropdownNameList && props.widgetData?.dropdownNameList[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 onExportClick = () => { if (props.widgetData?.csvData) { const csv = WidgetUtils.widgetCsvDataToString(props.widgetData); const fileName = WidgetUtils.makeExportFileName(props.widgetData, props.widgetMetaData); HtmlUtils.download(fileName, csv); } else { alert("There is no data available to export."); } }; /////////////////////////////////////////////////////////////////////////////////////////////////////// // add the export button to the label's left elements, if the meta-data says to show it // // don't do this for 2 types which themselves add the button (and have custom code to do the export) // /////////////////////////////////////////////////////////////////////////////////////////////////////// let localLabelAdditionalElementsLeft = [...props.labelAdditionalElementsLeft]; if (props.widgetMetaData?.showExportButton && props.widgetMetaData?.type !== "table" && props.widgetMetaData?.type !== "childRecordList") { if (!localLabelAdditionalElementsLeft) { localLabelAdditionalElementsLeft = []; } localLabelAdditionalElementsLeft.push(WidgetUtils.generateExportButton(onExportClick)); } 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 ||= (localLabelAdditionalElementsLeft && localLabelAdditionalElementsLeft.length > 0); needLabelBox ||= (labelComponentsRight && labelComponentsRight.length > 0); needLabelBox ||= isSet(props.widgetData?.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 // ////////////////////////////////////////////////////////////////////////////////////////// const isParentWidget = props.widgetMetaData.type == "parentWidget"; // todo - do we need to know top-level parent, vs. a nested parent? let labelToUse = props.widgetData?.label ?? props.widgetMetaData?.label; if (!labelToUse) { ///////////////////////////////////////////////////////////////////////////////////////////// // prevent the label from disappearing, especially when it's being used as the page header // ///////////////////////////////////////////////////////////////////////////////////////////// if (lastSeenLabel && isParentWidget && usingLabelAsTitle) { labelToUse = lastSeenLabel; } } let labelElement = ( {labelToUse} ); let sublabelElement = ( {props.widgetData?.sublabel} ); if (labelToUse && labelToUse != lastSeenLabel) { setLastSeenLabel(labelToUse); setUsingLabelAsTitle(props.widgetData.isLabelPageTitle); } const helpRoles = ["ALL_SCREENS"] const slotName = "label"; const showHelp = helpHelpActive || hasHelpContent(props.widgetMetaData?.helpContent?.get(slotName), helpRoles); if(showHelp) { const formattedHelpContent = ; labelElement = {labelElement}; } else if (props.widgetMetaData?.tooltip) { labelElement = {labelElement}; } const isTable = props.widgetMetaData.type == "table"; 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)}); }) ) } {localLabelAdditionalElementsLeft} { hasPermission && props.widgetData?.sublabel && (sublabelElement) } { 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"; /////////////////////////////////////////////////// // try to make tables fill their entire "parent" // /////////////////////////////////////////////////// let noCardMarginBottom = "unset"; if (isTable) { noCardMarginBottom = "-8px"; } return props.widgetMetaData?.isCard ? {widgetContent} : {widgetContent}; } export default Widget;