diff --git a/package.json b/package.json index 7b9f81a..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.62", + "@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} + + } { - widgetData[i] = await qController.widget(widgetMetaData.name, urlParams); - setWidgetData(widgetData); - setWidgetCounter(widgetCounter + 1); + try + { + widgetData[i] = await qController.widget(widgetMetaData.name, urlParams); + setWidgetData(widgetData); + setWidgetCounter(widgetCounter + 1); + if(widgetData[i]) + { + widgetData[i]["errorLoading"] = false; + } + } + catch(e) + { + console.error(e); + if(widgetData[i]) + { + widgetData[i]["errorLoading"] = true; + } + } + forceUpdate(); })(); } @@ -111,13 +127,31 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit { const urlParams = getQueryParams(widgetMetaDataList[index], data); setCurrentUrlParams(urlParams); + widgetData[index] = {}; + + try + { + widgetData[index] = await qController.widget(widgetMetaDataList[index].name, urlParams); + setWidgetCounter(widgetCounter + 1); + setWidgetData(widgetData); + + if (widgetData[index]) + { + widgetData[index]["errorLoading"] = false; + } + } + catch(e) + { + console.error(e); + if (widgetData[index]) + { + widgetData[index]["errorLoading"] = true; + } + } - widgetData[index] = await qController.widget(widgetMetaDataList[index].name, urlParams); - setWidgetCounter(widgetCounter + 1); - setWidgetData(widgetData); forceUpdate(); })(); - }; + } function getQueryParams(widgetMetaData: QWidgetMetaData, extraParams: string): string { @@ -221,20 +255,12 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit } { widgetMetaData.type === "table" && ( - reloadWidget(i, data)} - footerHTML={widgetData[i]?.footerHTML} isChild={areChildren} - > - - + /> ) } { @@ -254,7 +280,9 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit reloadWidget(i, data)}> + reloadWidgetCallback={(data) => reloadWidget(i, data)} + showReloadControl={false} + >
@@ -265,7 +293,9 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit widgetMetaData.type === "stepper" && ( + widgetData={widgetData[i]} + reloadWidgetCallback={(data) => reloadWidget(i, data)} + > @@ -276,7 +306,11 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit } { widgetMetaData.type === "html" && ( - + reloadWidget(i, data)} + widgetData={widgetData[i]} + > { @@ -306,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} >
@@ -379,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; @@ -61,6 +67,7 @@ interface Props Widget.defaultProps = { isChild: false, + showReloadControl: true, widgetMetaData: {}, widgetData: {}, labelAdditionalComponentsLeft: [], @@ -68,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
) + } } @@ -86,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} + + ); + } } @@ -97,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(); @@ -105,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; + tooltipTitle: string; + isDisabled: boolean; + + constructor(callbackToExport: any, isDisabled = false, tooltipTitle: string = "Export") + { + super(); + this.callbackToExport = callbackToExport; + this.isDisabled = isDisabled; + this.tooltipTitle = tooltipTitle; + } + + render = (args: LabelComponentRenderArgs): JSX.Element => + { + return ( + + + + ); + } } @@ -121,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 ( + + + + ); + } } @@ -132,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}) } @@ -209,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) { @@ -297,9 +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 ? @@ -360,14 +467,9 @@ function Widget(props: React.PropsWithChildren): JSX.Element ) ) } - {/* - - */} { hasPermission && ( - props.labelAdditionalComponentsLeft.map((component, i) => + effectiveLabelAdditionalComponentsLeft.map((component, i) => { return ({renderComponent(component, i)}); }) @@ -385,29 +487,44 @@ function Widget(props: React.PropsWithChildren): JSX.Element } + } { - hasPermission && props.widgetData?.dropdownNeedsSelectedText ? ( - - - {props.widgetData?.dropdownNeedsSelectedText} - + props.widgetMetaData?.isCard && (reloading ? : ) + } + { + errorLoading ? ( + + error + An error occurred loading widget content. ) : ( - hasPermission ? ( - props.children + hasPermission && props.widgetData?.dropdownNeedsSelectedText ? ( + + + {props.widgetData?.dropdownNeedsSelectedText} + + ) : ( - You do not have permission to view this data. + hasPermission ? ( + props.children + ) : ( + You do not have permission to view this data. + ) ) ) } { - props?.footerHTML && ( + ! errorLoading && props?.footerHTML && ( {parse(props.footerHTML)} ) } ; - 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..6f02ba8 100644 --- a/src/qqq/components/widgets/misc/RecordGridWidget.tsx +++ b/src/qqq/components/widgets/misc/RecordGridWidget.tsx @@ -25,9 +25,11 @@ import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; import {DataGridPro, GridCallbackDetails, GridRowParams, MuiEvent} from "@mui/x-data-grid-pro"; import React, {useEffect, useState} from "react"; import {useNavigate} from "react-router-dom"; -import Widget, {AddNewRecordButton, HeaderLink, LabelComponent} from "qqq/components/widgets/Widget"; +import Widget, {AddNewRecordButton, ExportDataButton, HeaderLink, LabelComponent} from "qqq/components/widgets/Widget"; import DataGridUtils from "qqq/utils/DataGridUtils"; +import HtmlUtils from "qqq/utils/HtmlUtils"; import Client from "qqq/utils/qqq/Client"; +import ValueUtils from "qqq/utils/qqq/ValueUtils"; interface Props { @@ -42,7 +44,9 @@ const qController = Client.getInstance(); function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element { const [rows, setRows] = useState([]); + const [records, setRecords] = useState([] as QRecord[]) const [columns, setColumns] = useState([]); + const [allColumns, setAllColumns] = useState([]) const navigate = useNavigate(); useEffect(() => @@ -68,6 +72,11 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element const childTablePath = data.tablePath ? data.tablePath + (data.tablePath.endsWith("/") ? "" : "/") : data.tablePath; const columns = DataGridUtils.setupGridColumns(tableMetaData, childTablePath, null, "bySection"); + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + // capture all-columns to use for the export (before we might splice some away from the on-screen display) // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + setAllColumns(JSON.parse(JSON.stringify(columns))); + //////////////////////////////////////////////////////////////// // do not not show the foreign-key column of the parent table // //////////////////////////////////////////////////////////////// @@ -84,16 +93,67 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element } setRows(rows); + setRecords(records) setColumns(columns); } }, [data]); + const exportCallback = () => + { + let csv = ""; + for (let i = 0; i < allColumns.length; i++) + { + csv += `${i > 0 ? "," : ""}"${ValueUtils.cleanForCsv(allColumns[i].headerName)}"` + } + csv += "\n"; + + for (let i = 0; i < records.length; i++) + { + for (let j = 0; j < allColumns.length; j++) + { + const value = records[i].displayValues.get(allColumns[j].field) ?? records[i].values.get(allColumns[j].field) + csv += `${j > 0 ? "," : ""}"${ValueUtils.cleanForCsv(value)}"` + } + csv += "\n"; + } + + const fileName = (data?.label ?? widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv"; + HtmlUtils.download(fileName, csv); + } + + /////////////////// + // view all link // + /////////////////// const labelAdditionalComponentsLeft: LabelComponent[] = [] if(data && data.viewAllLink) { labelAdditionalComponentsLeft.push(new HeaderLink("View All", data.viewAllLink)); } + /////////////////// + // export button // + /////////////////// + let isExportDisabled = true; + let tooltipTitle = "Export"; + if (data && data.childTableMetaData && data.queryOutput && data.queryOutput.records && data.queryOutput.records.length > 0) + { + isExportDisabled = false; + + if(data.totalRows && data.queryOutput.records.length < data.totalRows) + { + tooltipTitle = "Export these " + data.queryOutput.records.length + " records." + if(data.viewAllLink) + { + tooltipTitle += "\nClick View All to export all records."; + } + } + } + + labelAdditionalComponentsLeft.push(new ExportDataButton(() => exportCallback(), isExportDisabled, tooltipTitle)) + + //////////////////// + // add new button // + //////////////////// const labelAdditionalComponentsRight: LabelComponent[] = [] if(data && data.canAddChildRecord) { @@ -123,6 +183,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..a9e98c4 --- /dev/null +++ b/src/qqq/components/widgets/tables/TableWidget.tsx @@ -0,0 +1,131 @@ +/* + * 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 HtmlUtils from "qqq/utils/HtmlUtils"; +import ValueUtils from "qqq/utils/qqq/ValueUtils"; + +interface Props +{ + widgetMetaData?: QWidgetMetaData; + widgetData?: WidgetData; + reloadWidgetCallback?: (params: string) => void; + isChild?: boolean; +} + +TableWidget.defaultProps = { +}; + +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"; + HtmlUtils.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/apps/Home.tsx b/src/qqq/pages/apps/Home.tsx index db4f326..58639d6 100644 --- a/src/qqq/pages/apps/Home.tsx +++ b/src/qqq/pages/apps/Home.tsx @@ -26,7 +26,8 @@ import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/ import {QReportMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QReportMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData"; -import {Box, Icon, Typography} from "@mui/material"; +import {Icon, Typography} from "@mui/material"; +import Box from "@mui/material/Box"; import Card from "@mui/material/Card"; import Divider from "@mui/material/Divider"; import Grid from "@mui/material/Grid"; @@ -316,7 +317,7 @@ function AppHome({app}: Props): JSX.Element {hasTablePermission(tableName) ? - + + { + 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"; + HtmlUtils.download(fileName, csv); + } + function Loading() { return ( @@ -200,9 +216,14 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro {statusString ?? <> } - + + + + diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index 6bdd6a1..cd7bcd1 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -386,16 +386,15 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } font-size: 0.875rem; } -.MuiGrid-root > .MuiBox-root > .material-icons-round, -.MuiBox-root > .MuiBox-root > .material-icons-round +.big-icon .material-icons-round { font-size: 2rem !important; } .dashboard-schedule-icon { - font-size: 1rem !important; + font-size: 1.1rem !important; position: relative; - top: -13px; - margin-right: 3px; + top: -5px; + margin-right: 8px; } diff --git a/src/qqq/utils/HtmlUtils.ts b/src/qqq/utils/HtmlUtils.ts index 8b4887e..5197867 100644 --- a/src/qqq/utils/HtmlUtils.ts +++ b/src/qqq/utils/HtmlUtils.ts @@ -19,6 +19,9 @@ * along with this program. If not, see . */ +/******************************************************************************* + ** Utility functions for basic html/webpage/browser things. + *******************************************************************************/ export default class HtmlUtils { @@ -39,4 +42,21 @@ export default class HtmlUtils }); }; + /******************************************************************************* + ** Download a client-side generated file (e.g., csv). + *******************************************************************************/ + static 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); + }; + } \ No newline at end of file diff --git a/src/qqq/utils/qqq/FilterUtils.ts b/src/qqq/utils/qqq/FilterUtils.ts index 7c56bf6..0aebaf7 100644 --- a/src/qqq/utils/qqq/FilterUtils.ts +++ b/src/qqq/utils/qqq/FilterUtils.ts @@ -65,7 +65,7 @@ class FilterUtils return QCriteriaOperator.EQUALS; case "isNot": case "!=": - return QCriteriaOperator.NOT_EQUALS; + return QCriteriaOperator.NOT_EQUALS_OR_IS_NULL; case "after": case ">": return QCriteriaOperator.GREATER_THAN; @@ -138,6 +138,7 @@ class FilterUtils return ("is"); } case QCriteriaOperator.NOT_EQUALS: + case QCriteriaOperator.NOT_EQUALS_OR_IS_NULL: if (field.possibleValueSourceName) { 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, "\"\"")); + } } ////////////////////////////////////////////////////////////////////////////////////////////////