diff --git a/package.json b/package.json index bda2769..65b58a2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", - "@kingsrook/qqq-frontend-core": "1.0.63", + "@kingsrook/qqq-frontend-core": "1.0.66", "@mui/icons-material": "5.4.1", "@mui/material": "5.11.1", "@mui/styles": "5.11.1", @@ -30,6 +30,7 @@ "form-data": "4.0.0", "formik": "2.2.9", "html-react-parser": "1.4.8", + "html-to-text": "^9.0.5", "http-proxy-middleware": "2.0.6", "rapidoc": "9.3.4", "react": "17.0.2", diff --git a/src/App.tsx b/src/App.tsx index 98321cd..8b5c118 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -567,6 +567,7 @@ export default function App() icon={branding.icon} logo={branding.logo} appName={branding.appName} + branding={branding} routes={sideNavRoutes} onMouseEnter={handleOnMouseEnter} onMouseLeave={handleOnMouseLeave} diff --git a/src/qqq/components/horseshoe/sidenav/SideNav.tsx b/src/qqq/components/horseshoe/sidenav/SideNav.tsx index 30d2251..8ac14e9 100644 --- a/src/qqq/components/horseshoe/sidenav/SideNav.tsx +++ b/src/qqq/components/horseshoe/sidenav/SideNav.tsx @@ -19,6 +19,7 @@ * along with this program. If not, see . */ +import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData"; import Box from "@mui/material/Box"; import Divider from "@mui/material/Divider"; import Icon from "@mui/material/Icon"; @@ -42,6 +43,7 @@ interface Props icon?: string; logo?: string; appName?: string; + branding?: QBrandingMetaData; routes: { [key: string]: | ReactNode @@ -64,7 +66,7 @@ interface Props [key: string]: any; } -function Sidenav({color, icon, logo, appName, routes, ...rest}: Props): JSX.Element +function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props): JSX.Element { const [openCollapse, setOpenCollapse] = useState(false); const [openNestedCollapse, setOpenNestedCollapse] = useState(false); @@ -328,6 +330,12 @@ function Sidenav({color, icon, logo, appName, routes, ...rest}: Props): JSX.Elem } + { + branding && branding.environmentBannerText && + + {branding.environmentBannerText} + + } reloadWidget(i, data)} - footerHTML={widgetData[i]?.footerHTML} isChild={areChildren} - > - - + /> ) } { @@ -288,7 +280,9 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit reloadWidget(i, data)}> + reloadWidgetCallback={(data) => reloadWidget(i, data)} + showReloadControl={false} + >
@@ -299,7 +293,9 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit widgetMetaData.type === "stepper" && ( + widgetData={widgetData[i]} + reloadWidgetCallback={(data) => reloadWidget(i, data)} + > @@ -310,7 +306,11 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit } { widgetMetaData.type === "html" && ( - + reloadWidget(i, data)} + widgetData={widgetData[i]} + > { @@ -340,8 +340,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit widgetMetaData={widgetMetaData} widgetData={widgetData[i]} isChild={areChildren} - - // reloadWidgetCallback={(data) => reloadWidget(i, data)} + reloadWidgetCallback={(data) => reloadWidget(i, data)} > reloadWidget(i, data)} isChild={areChildren} >
@@ -413,7 +413,9 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit + reloadWidgetCallback={(data) => reloadWidget(i, data)} + isChild={areChildren} + > void; + showReloadControl: boolean; isChild?: boolean; footerHTML?: string; storeDropdownSelections?: boolean; @@ -62,6 +67,7 @@ interface Props Widget.defaultProps = { isChild: false, + showReloadControl: true, widgetMetaData: {}, widgetData: {}, labelAdditionalComponentsLeft: [], @@ -69,9 +75,22 @@ Widget.defaultProps = { }; +interface LabelComponentRenderArgs +{ + navigate: NavigateFunction; + widgetProps: Props; + dropdownData: any[]; + componentIndex: number; + reloadFunction: () => void; +} + + export class LabelComponent { - + render = (args: LabelComponentRenderArgs): JSX.Element => + { + return (
Unsupported component type
) + } } @@ -87,6 +106,15 @@ export class HeaderLink extends LabelComponent this.label = label; this.to = to; } + + render = (args: LabelComponentRenderArgs): JSX.Element => + { + return ( + + {this.to ? {this.label} : null} + + ); + } } @@ -98,6 +126,7 @@ export class AddNewRecordButton extends LabelComponent defaultValues: any; disabledFields: any; + constructor(table: QTableMetaData, defaultValues: any, label: string = "Add new", disabledFields: any = defaultValues) { super(); @@ -106,6 +135,45 @@ export class AddNewRecordButton extends LabelComponent 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 ExportDataButton extends LabelComponent +{ + callbackToExport: any; + label: string; + isDisabled: boolean; + + constructor(callbackToExport: any, isDisabled = false, label: string = "Export") + { + super(); + this.callbackToExport = callbackToExport; + this.isDisabled = isDisabled; + this.label = label; + } + + render = (args: LabelComponentRenderArgs): JSX.Element => + { + return ( + + + + ); + } } @@ -122,6 +190,55 @@ export class Dropdown extends LabelComponent this.options = options; this.onChangeCallback = onChangeCallback; } + + render = (args: LabelComponentRenderArgs): JSX.Element => + { + let defaultValue = null; + const dropdownName = args.widgetProps.widgetData.dropdownNameList[args.componentIndex]; + const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${args.widgetProps.widgetMetaData.name}.${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; + } + + return ( + + + + ); + } +} + + +export class ReloadControl extends LabelComponent +{ + callback: () => void; + + constructor(callback: () => void) + { + super(); + this.callback = callback; + } + + render = (args: LabelComponentRenderArgs): JSX.Element => + { + return ( + + + + ); + } } @@ -133,64 +250,11 @@ function Widget(props: React.PropsWithChildren): JSX.Element const navigate = useNavigate(); const [dropdownData, setDropdownData] = useState([]); const [fullScreenWidgetClassName, setFullScreenWidgetClassName] = useState(""); + const [reloading, setReloading] = useState(false); - function openEditForm(table: QTableMetaData, id: any = null, defaultValues: any, disabledFields: any) + function renderComponent(component: LabelComponent, componentIndex: number) { - navigate(`#/createChild=${table.name}/defaultValues=${JSON.stringify(defaultValues)}/disabledFields=${JSON.stringify(disabledFields)}`) - } - - function renderComponent(component: LabelComponent, index: number) - { - if(component instanceof HeaderLink) - { - const link = component as HeaderLink - return ( - - {link.to ? {link.label} : null} - - ); - } - - if (component instanceof AddNewRecordButton) - { - const addNewRecordButton = component as AddNewRecordButton - return ( - - - - ); - } - - if (component instanceof Dropdown) - { - let defaultValue = null; - const dropdownName = props.widgetData.dropdownNameList[index]; - const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${props.widgetMetaData.name}.${dropdownName}`; - if(props.storeDropdownSelections) - { - /////////////////////////////////////////////////////////////////////////////////////// - // see if an existing value is stored in local storage, and if so set it in dropdown // - /////////////////////////////////////////////////////////////////////////////////////// - defaultValue = JSON.parse(localStorage.getItem(localStorageKey)); - dropdownData[index] = defaultValue?.id; - } - - const dropdown = component as Dropdown - return ( - - - - ); - } - - return (
Unsupported component type.
) + return component.render({navigate: navigate, widgetProps: props, dropdownData: dropdownData, componentIndex: componentIndex, reloadFunction: doReload}) } @@ -210,6 +274,26 @@ function Widget(props: React.PropsWithChildren): JSX.Element }); } + const doReload = () => + { + setReloading(true); + reloadWidget(dropdownData); + } + + useEffect(() => + { + setReloading(false); + }, [props.widgetData]); + + const effectiveLabelAdditionalComponentsLeft: LabelComponent[] = []; + if(props.reloadWidgetCallback && props.widgetData && props.showReloadControl && props.widgetMetaData.showReloadButton) + { + effectiveLabelAdditionalComponentsLeft.push(new ReloadControl(doReload)) + } + if(props.labelAdditionalComponentsLeft) + { + props.labelAdditionalComponentsLeft.map((component) => effectiveLabelAdditionalComponentsLeft.push(component)); + } function handleDataChange(dropdownLabel: string, changedData: any) { @@ -298,10 +382,31 @@ function Widget(props: React.PropsWithChildren): JSX.Element } 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 ||= (effectiveLabelAdditionalComponentsLeft && effectiveLabelAdditionalComponentsLeft.length > 0); + needLabelBox ||= (effectiveLabelAdditionalComponentsRight && effectiveLabelAdditionalComponentsRight.length > 0); + needLabelBox ||= isSet(props.widgetMetaData?.icon); + needLabelBox ||= isSet(props.widgetData?.label); + needLabelBox ||= isSet(props.widgetMetaData?.label); + } + const errorLoading = props.widgetData?.errorLoading !== undefined && props.widgetData?.errorLoading === true; const widgetContent = - + { + needLabelBox && + { hasPermission ? @@ -364,7 +469,7 @@ function Widget(props: React.PropsWithChildren): JSX.Element } { hasPermission && ( - props.labelAdditionalComponentsLeft.map((component, i) => + effectiveLabelAdditionalComponentsLeft.map((component, i) => { return ({renderComponent(component, i)}); }) @@ -382,6 +487,10 @@ function Widget(props: React.PropsWithChildren): JSX.Element } + } + { + props.widgetMetaData?.isCard && (reloading ? : ) + } { errorLoading ? ( @@ -411,7 +520,11 @@ function Widget(props: React.PropsWithChildren): JSX.Element } ; - return props.widgetMetaData?.isCard ? {widgetContent} : widgetContent; + return props.widgetMetaData?.isCard + ? + {widgetContent} + + : widgetContent; } export default Widget; diff --git a/src/qqq/components/widgets/misc/RecordGridWidget.tsx b/src/qqq/components/widgets/misc/RecordGridWidget.tsx index 419934b..2daa5fc 100644 --- a/src/qqq/components/widgets/misc/RecordGridWidget.tsx +++ b/src/qqq/components/widgets/misc/RecordGridWidget.tsx @@ -123,6 +123,7 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element return ( diff --git a/src/qqq/components/widgets/tables/TableWidget.tsx b/src/qqq/components/widgets/tables/TableWidget.tsx new file mode 100644 index 0000000..0f66067 --- /dev/null +++ b/src/qqq/components/widgets/tables/TableWidget.tsx @@ -0,0 +1,145 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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 {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData"; +// @ts-ignore +import {htmlToText} from "html-to-text"; +import React, {useEffect, useState} from "react"; +import TableCard from "qqq/components/widgets/tables/TableCard"; +import Widget, {ExportDataButton, WidgetData} from "qqq/components/widgets/Widget"; +import ValueUtils from "qqq/utils/qqq/ValueUtils"; + +interface Props +{ + widgetMetaData?: QWidgetMetaData; + widgetData?: WidgetData; + reloadWidgetCallback?: (params: string) => void; + isChild?: boolean; +} + +TableWidget.defaultProps = { + foo: null, +}; + +function download(filename: string, text: string) +{ + var element = document.createElement("a"); + element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(text)); + element.setAttribute("download", filename); + + element.style.display = "none"; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); +} + +function TableWidget(props: Props): JSX.Element +{ + const [isExportDisabled, setIsExportDisabled] = useState(true); + + const rows = props.widgetData?.rows; + const columns = props.widgetData?.columns; + + useEffect(() => + { + let isExportDisabled = true; + if (props.widgetData && columns && rows && rows.length > 0) + { + isExportDisabled = false; + } + setIsExportDisabled(isExportDisabled); + + }, [props.widgetMetaData, props.widgetData]); + + const exportCallback = () => + { + if (props.widgetData && rows && columns) + { + console.log(props.widgetData); + + let csv = ""; + for (let j = 0; j < columns.length; j++) + { + if (j > 0) + { + csv += ","; + } + csv += `"${columns[j].header}"`; + } + csv += "\n"; + + for (let i = 0; i < rows.length; i++) + { + for (let j = 0; j < columns.length; j++) + { + if (j > 0) + { + csv += ","; + } + + const cell = rows[i][columns[j].accessor]; + const text = htmlToText(cell, + { + selectors: [ + {selector: "a", format: "inline"}, + {selector: ".MuiIcon-root", format: "skip"}, + {selector: ".button", format: "skip"} + ] + }); + csv += `"${ValueUtils.cleanForCsv(text)}"`; + } + csv += "\n"; + } + + console.log(csv); + + const fileName = (props.widgetData.label ?? props.widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv"; + download(fileName, csv); + } + else + { + alert("There is no data available to export."); + } + }; + + return ( + props.reloadWidgetCallback(data)} + footerHTML={props.widgetData?.footerHTML} + isChild={props.isChild} + labelAdditionalComponentsLeft={props.widgetMetaData?.showExportButton ? [new ExportDataButton(() => exportCallback(), isExportDisabled)] : []} + > + + + ); +} + +export default TableWidget; diff --git a/src/qqq/pages/records/query/ColumnStats.tsx b/src/qqq/pages/records/query/ColumnStats.tsx index e84dab0..65d6077 100644 --- a/src/qqq/pages/records/query/ColumnStats.tsx +++ b/src/qqq/pages/records/query/ColumnStats.tsx @@ -54,6 +54,21 @@ ColumnStats.defaultProps = { const qController = Client.getInstance(); +// todo - merge w/ same function in TableWidget +function download(filename: string, text: string) +{ + var element = document.createElement("a"); + element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(text)); + element.setAttribute("download", filename); + + element.style.display = "none"; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); +} + function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Props): JSX.Element { const [statusString, setStatusString] = useState("Calculating statistics..."); @@ -97,6 +112,8 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro } else { + // todo - job running! + const result = processResult as QJobComplete; const statFieldObjects = result.values.statsFields; @@ -174,6 +191,20 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro setStatusString("Refreshing...") } + const doExport = () => + { + let csv = `"${ValueUtils.cleanForCsv(fieldMetaData.label)}","Count"\n`; + for (let i = 0; i < valueCounts.length; i++) + { + const fieldValue = valueCounts[i].displayValues.get(fieldMetaData.name); + const count = valueCounts[i].values.get("count"); + csv += `"${ValueUtils.cleanForCsv(fieldValue)}",${count}\n`; + } + + const fileName = tableMetaData.label + " - " + fieldMetaData.label + " Column Stats " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv"; + download(fileName, csv); + } + function Loading() { return ( @@ -200,9 +231,14 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro {statusString ?? <> } - + + + + diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index 655c22f..d8c1332 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -273,6 +273,14 @@ class ValueUtils return (`${date.toString("yyyy-MM-ddTHH:mm:ssZ")}`); } + public static formatDateTimeForFileName(date: Date) + { + const zp = (value: number): string => (value < 10 ? `0${value}` : `${value}`); + const d = new Date(); + const dateString = `${d.getFullYear()}-${zp(d.getMonth() + 1)}-${zp(d.getDate())} ${zp(d.getHours())}${zp(d.getMinutes())}`; + return (dateString); + } + public static getFullWeekday(date: Date) { if (!(date instanceof Date)) @@ -410,6 +418,19 @@ class ValueUtils return toPush; } + + /******************************************************************************* + ** for building CSV in frontends, cleanse null & undefined, and escape "'s + *******************************************************************************/ + public static cleanForCsv(param: any): string + { + if(param === undefined || param === null) + { + return (""); + } + + return (String(param).replaceAll(/"/g, "\"\"")); + } } ////////////////////////////////////////////////////////////////////////////////////////////////