@@ -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, "\"\""));
+ }
}
////////////////////////////////////////////////////////////////////////////////////////////////