diff --git a/src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx b/src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx new file mode 100644 index 0000000..336e2dd --- /dev/null +++ b/src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx @@ -0,0 +1,573 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData"; +import Autocomplete from "@mui/material/Autocomplete"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Grid from "@mui/material/Grid"; +import Icon from "@mui/material/Icon"; +import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip/Tooltip"; +import colors from "qqq/assets/theme/base/colors"; +import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; +import Widget, {HeaderToggleComponent} from "qqq/components/widgets/Widget"; +import Client from "qqq/utils/qqq/Client"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; +import React, {useEffect, useReducer, useState} from "react"; + +/////////////////////////////////////////////////////////////////////////////// +// put a unique key value in all the pivot table group-by and value objects, // +// to help react rendering be sane. // +/////////////////////////////////////////////////////////////////////////////// +let pivotObjectKey = new Date().getTime(); + +interface PivotTableSetupWidgetProps +{ + isEditable: boolean; + widgetMetaData: QWidgetMetaData; + recordValues: { [name: string]: any }; + onSaveCallback?: (values: { [name: string]: any }) => void; +} + +PivotTableSetupWidget.defaultProps = { + onSaveCallback: null +}; + +export class PivotTableDefinition +{ + rows: PivotTableGroupBy[]; + columns: PivotTableGroupBy[]; + values: PivotTableValue[]; +} + +export class PivotTableGroupBy +{ + fieldName: string; + key: number; + + constructor() + { + this.key = pivotObjectKey++; + } +} + +export class PivotTableValue +{ + fieldName: string; + function: PivotTableFunction; + + key: number; + + constructor() + { + this.key = pivotObjectKey++; + } +} + +enum PivotTableFunction +{ + AVERAGE = "AVERAGE", + COUNT = "COUNT", + COUNT_NUMS = "COUNT_NUMS", + MAX = "MAX", + MIN = "MIN", + PRODUCT = "PRODUCT", + STD_DEV = "STD_DEV", + STD_DEVP = "STD_DEVP", + SUM = "SUM", + VAR = "VAR", + VARP = "VARP", +} + +const pivotTableFunctionLabels = + { + "AVERAGE": "Average", + "COUNT": "Count Values (COUNTA)", + "COUNT_NUMS": "Count Numbers (COUNT)", + "MAX": "Max", + "MIN": "Min", + "PRODUCT": "Product", + "STD_DEV": "StdDev", + "STD_DEVP": "StdDevp", + "SUM": "Sum", + "VAR": "Var", + "VARP": "Varp" + }; + + +const qController = Client.getInstance(); + +/******************************************************************************* + ** Component to edit the setup of a Pivot Table - rows, columns, values! + *******************************************************************************/ +export default function PivotTableSetupWidget({isEditable, widgetMetaData, recordValues, onSaveCallback}: PivotTableSetupWidgetProps): JSX.Element +{ + const [metaData, setMetaData] = useState(null as QInstance); + const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData); + + const [enabled, setEnabled] = useState(!!recordValues["usePivotTable"]); + + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + const [pivotTableDefinition, setPivotTableDefinition] = useState(null as PivotTableDefinition); + + const [usedGroupByFieldNames, setUsedGroupByFieldNames] = useState([] as string[]); + + + ////////////////// + // initial load // + ////////////////// + useEffect(() => + { + (async () => + { + if (!pivotTableDefinition) + { + let originalPivotTableDefinition = recordValues["pivotTableJson"] && JSON.parse(recordValues["pivotTableJson"]) as PivotTableDefinition; + if (originalPivotTableDefinition) + { + setEnabled(true); + } + else if (!originalPivotTableDefinition) + { + originalPivotTableDefinition = new PivotTableDefinition(); + } + + for (let i = 0; i < originalPivotTableDefinition?.rows?.length; i++) + { + if (!originalPivotTableDefinition?.rows[i].key) + { + originalPivotTableDefinition.rows[i].key = pivotObjectKey++; + } + } + + for (let i = 0; i < originalPivotTableDefinition?.columns?.length; i++) + { + if (!originalPivotTableDefinition?.columns[i].key) + { + originalPivotTableDefinition.columns[i].key = pivotObjectKey++; + } + } + + for (let i = 0; i < originalPivotTableDefinition?.values?.length; i++) + { + if (!originalPivotTableDefinition?.values[i].key) + { + originalPivotTableDefinition.values[i].key = pivotObjectKey++; + } + } + + setPivotTableDefinition(originalPivotTableDefinition); + } + + setMetaData(await qController.loadMetaData()); + })(); + }); + + ///////////////////////////////////////////////////////////////////// + // handle the table name changing - load current table's meta-data // + ///////////////////////////////////////////////////////////////////// + useEffect(() => + { + if (recordValues["tableName"] && (tableMetaData == null || tableMetaData.name != recordValues["tableName"])) + { + (async () => + { + const tableMetaData = await qController.loadTableMetaData(recordValues["tableName"]); + setTableMetaData(tableMetaData); + })(); + } + }, [recordValues]); + + + /******************************************************************************* + ** + *******************************************************************************/ + function toggleEnabled() + { + const newEnabled = !!!getEnabled(); + setEnabled(newEnabled); + onSaveCallback({usePivotTable: newEnabled}); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function getEnabled() + { + return (enabled); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function addGroupBy(rowsOrColumns: "rows" | "columns") + { + if (!pivotTableDefinition[rowsOrColumns]) + { + pivotTableDefinition[rowsOrColumns] = []; + } + + pivotTableDefinition[rowsOrColumns].push(new PivotTableGroupBy()); + onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); + forceUpdate(); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function removeGroupBy(index: number, rowsOrColumns: "rows" | "columns") + { + pivotTableDefinition[rowsOrColumns].splice(index, 1); + onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); + updateUsedGroupByFieldNames(); + forceUpdate(); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function addValue() + { + if (!pivotTableDefinition.values) + { + pivotTableDefinition.values = []; + } + + pivotTableDefinition.values.push(new PivotTableValue()); + onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); + forceUpdate(); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function removeValue(index: number) + { + pivotTableDefinition.values.splice(index, 1); + onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); + forceUpdate(); + } + + + const buttonSX = + { + border: `1px solid ${colors.grayLines.main} !important`, + borderRadius: "0.75rem", + textTransform: "none", + fontSize: "1rem", + fontWeight: "400", + width: "160px", + paddingLeft: 0, + paddingRight: 0, + color: colors.dark.main, + "&:hover": {color: colors.dark.main}, + "&:focus": {color: colors.dark.main}, + "&:focus:not(:hover)": {color: colors.dark.main}, + }; + + const xIconButtonSX = + { + border: `1px solid ${colors.grayLines.main} !important`, + borderRadius: "0.75rem", + textTransform: "none", + fontSize: "1rem", + fontWeight: "400", + width: "40px", + minWidth: "40px", + paddingLeft: 0, + paddingRight: 0, + color: colors.error.main, + "&:hover": {color: colors.error.main}, + "&:focus": {color: colors.error.main}, + "&:focus:not(:hover)": {color: colors.error.main}, + }; + + const fieldAutoCompleteTextFieldSX = + { + "& .MuiInputBase-input": {fontSize: "1rem", padding: "0 !important"} + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + function updateUsedGroupByFieldNames() + { + const hiddenFieldNames: string[] = []; + + for (let i = 0; i < pivotTableDefinition?.rows?.length; i++) + { + hiddenFieldNames.push(pivotTableDefinition?.rows[i].fieldName); + } + + for (let i = 0; i < pivotTableDefinition?.columns?.length; i++) + { + hiddenFieldNames.push(pivotTableDefinition?.columns[i].fieldName); + } + + setUsedGroupByFieldNames(hiddenFieldNames); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function getSelectedFieldForAutoComplete(fieldName: string) + { + if (fieldName) + { + let [field, fieldTable] = FilterUtils.getField(tableMetaData, fieldName); + if (field && fieldTable) + { + return ({field: field, table: fieldTable, fieldName: fieldName}); + } + } + + return (null); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function renderOneGroupBy(groupBy: PivotTableGroupBy, index: number, rowsOrColumns: "rows" | "columns") + { + if(!isEditable) + { + const selectedField = getSelectedFieldForAutoComplete(groupBy.fieldName); + if(selectedField) + { + const label = selectedField.table.name == tableMetaData.name ? selectedField.field.label : selectedField.table.label + ": " + selectedField.field.label + return ({label}); + } + + return (); + } + + const handleFieldChange = (event: any, newValue: any, reason: string) => + { + groupBy.fieldName = newValue ? newValue.fieldName : null; + onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); + updateUsedGroupByFieldNames(); + }; + + // maybe cursor:grab (and then change to "grabbing") + return ( + + drag_indicator + + + + + + + + ); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function renderOneValue(value: PivotTableValue, index: number) + { + if(!isEditable) + { + const selectedField = getSelectedFieldForAutoComplete(value.fieldName); + if(selectedField && value.function) + { + const label = selectedField.table.name == tableMetaData.name ? selectedField.field.label : selectedField.table.label + ": " + selectedField.field.label + return ({pivotTableFunctionLabels[value.function]} of {label}); + } + + return (); + } + + const handleFieldChange = (event: any, newValue: any, reason: string) => + { + value.fieldName = newValue ? newValue.fieldName : null; + onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); + }; + + const handleFunctionChange = (event: any, newValue: any, reason: string) => + { + value.function = newValue ? newValue.id : null; + onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); + }; + + const functionOptions: any[] = []; + let defaultFunctionValue = null; + for (let pivotTableFunctionKey in PivotTableFunction) + { + // @ts-ignore any? + const label = "" + pivotTableFunctionLabels[pivotTableFunctionKey]; + const option = {id: pivotTableFunctionKey, label: label}; + functionOptions.push(option); + + if(option.id == value.function) + { + defaultFunctionValue = option; + } + } + + // maybe cursor:grab (and then change to "grabbing") + return ( + + drag_indicator + + + + + + ()} + // @ts-ignore + defaultValue={defaultFunctionValue} + options={functionOptions} + onChange={handleFunctionChange} + isOptionEqualToValue={(option, value) => option.id === value.id} + getOptionLabel={(option) => option.label} + // todo? renderOption={(props, option, state) => renderFieldOption(props, option, state)} + autoSelect={true} + autoHighlight={true} + disableClearable + // slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}} + // {...alsoOpen} + /> + + + + + ); + } + + + ///////////////////////////////////////////////////////////// + // add toggle component to widget header for editable mode // + ///////////////////////////////////////////////////////////// + const labelAdditionalElementsRight: JSX.Element[] = []; + if (isEditable) + { + labelAdditionalElementsRight.push( enabled} onClickCallback={toggleEnabled} />); + } + + const selectTableFirstTooltipTitle = tableMetaData ? null : "You must select a table before you can set up a pivot table"; + + return ( + {enabled && pivotTableDefinition && + + + + +
Rows
+ + { + tableMetaData && pivotTableDefinition.rows?.map((row: PivotTableGroupBy, index: number) => + ( + {renderOneGroupBy(row, index, "rows")} + )) + } + + { + isEditable && + + + + + + } +
+ + +
Columns
+ + { + tableMetaData && pivotTableDefinition.columns?.map((column: PivotTableGroupBy, index: number) => + ( + {renderOneGroupBy(column, index, "columns")} + )) + } + + { + isEditable && + + + + + + } +
+ + +
Values
+ + { + tableMetaData && pivotTableDefinition.values?.map((value: PivotTableValue, index: number) => + ( + {renderOneValue(value, index)} + )) + } + + { + isEditable && + + + + + + } +
+ +
+
+ } +
); +}