/* * 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 Alert from "@mui/material/Alert"; import Autocomplete from "@mui/material/Autocomplete"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; import Grid from "@mui/material/Grid"; import Icon from "@mui/material/Icon"; import Modal from "@mui/material/Modal"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip/Tooltip"; import Typography from "@mui/material/Typography"; import QContext from "QContext"; import colors from "qqq/assets/theme/base/colors"; import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent"; import {PivotTableGroupByElement} from "qqq/components/widgets/misc/PivotTableGroupByElement"; import {PivotTableValueElement} from "qqq/components/widgets/misc/PivotTableValueElement"; import {buttonSX, unborderedButtonSX} from "qqq/components/widgets/misc/ReportSetupWidget"; import Widget, {HeaderToggleComponent} from "qqq/components/widgets/Widget"; import {PivotObjectKey, PivotTableDefinition, PivotTableFunction, pivotTableFunctionLabels, PivotTableGroupBy, PivotTableValue} from "qqq/models/misc/PivotTableDefinitionModels"; import QQueryColumns from "qqq/models/query/QQueryColumns"; import Client from "qqq/utils/qqq/Client"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; import React, {useCallback, useContext, useEffect, useReducer, useState} from "react"; import {DndProvider} from "react-dnd"; import {HTML5Backend} from "react-dnd-html5-backend"; export const DragItemTypes = { ROW: "row", COLUMN: "column", VALUE: "value" }; export 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}, }; export const fieldAutoCompleteTextFieldSX = { "& .MuiInputBase-input": {fontSize: "1rem", padding: "0 !important"} }; /******************************************************************************* ** *******************************************************************************/ export function getSelectedFieldForAutoComplete(tableMetaData: QTableMetaData, fieldName: string) { if (fieldName) { let [field, fieldTable] = FilterUtils.getField(tableMetaData, fieldName); if (field && fieldTable) { return ({field: field, table: fieldTable, fieldName: fieldName}); } } return (null); } /******************************************************************************* ** component props *******************************************************************************/ interface PivotTableSetupWidgetProps { isEditable: boolean; widgetMetaData: QWidgetMetaData; recordValues: { [name: string]: any }; onSaveCallback?: (values: { [name: string]: any }) => void; } /******************************************************************************* ** default values for props *******************************************************************************/ PivotTableSetupWidget.defaultProps = { onSaveCallback: null }; 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 [modalOpen, setModalOpen] = useState(false); const [enabled, setEnabled] = useState(!!recordValues["usePivotTable"]); const [attemptedSubmit, setAttemptedSubmit] = useState(false); const [errorAlert, setErrorAlert] = useState(null as string); const [pivotTableDefinition, setPivotTableDefinition] = useState(null as PivotTableDefinition); const [, forceUpdate] = useReducer((x) => x + 1, 0); /////////////////////////////////////////////////////////////////////////////////// // this is a copy of pivotTableDefinition, that we'll render in the modal. // // then on-save, we'll move it to pivotTableDefinition, e.g., the actual record. // /////////////////////////////////////////////////////////////////////////////////// const [modalPivotTableDefinition, setModalPivotTableDefinition] = useState(null as PivotTableDefinition); const [usedGroupByFieldNames, setUsedGroupByFieldNames] = useState([] as string[]); const [usedValueFieldNames, setUsedValueByFieldNames] = useState([] as string[]); const [availableFieldNames, setAvailableFieldNames] = useState([] as string[]); const {helpHelpActive} = useContext(QContext); ////////////////// // initial load // ////////////////// useEffect(() => { 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.next(); } } for (let i = 0; i < originalPivotTableDefinition?.columns?.length; i++) { if (!originalPivotTableDefinition?.columns[i].key) { originalPivotTableDefinition.columns[i].key = PivotObjectKey.next(); } } for (let i = 0; i < originalPivotTableDefinition?.values?.length; i++) { if (!originalPivotTableDefinition?.values[i].key) { originalPivotTableDefinition.values[i].key = PivotObjectKey.next(); } } setPivotTableDefinition(originalPivotTableDefinition); updateUsedGroupByFieldNames(originalPivotTableDefinition); updateUsedValueFieldNames(modalPivotTableDefinition); } if (recordValues["columnsJson"]) { updateAvailableFieldNames(JSON.parse(recordValues["columnsJson"]) as QQueryColumns); } (async () => { 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]); const helpRoles = isEditable ? [recordValues["id"] ? "EDIT_SCREEN" : "INSERT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"] : ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"]; /******************************************************************************* ** *******************************************************************************/ function showHelp(slot: string) { return (helpHelpActive || hasHelpContent(widgetMetaData?.helpContent?.get(slot), helpRoles)); } /******************************************************************************* ** *******************************************************************************/ function getHelpContent(slot: string) { const key = `widget:${widgetMetaData.name};slot:${slot}`; return ; } /******************************************************************************* ** *******************************************************************************/ function toggleEnabled() { const newEnabled = !!!getEnabled(); setEnabled(newEnabled); onSaveCallback({usePivotTable: newEnabled}); if (!newEnabled) { onSaveCallback({pivotTableJson: null}); } } /******************************************************************************* ** *******************************************************************************/ function getEnabled() { return (enabled); } /******************************************************************************* ** *******************************************************************************/ function addGroupBy(rowsOrColumns: "rows" | "columns") { if (!modalPivotTableDefinition[rowsOrColumns]) { modalPivotTableDefinition[rowsOrColumns] = []; } modalPivotTableDefinition[rowsOrColumns].push(new PivotTableGroupBy()); validateForm(); forceUpdate(); } /******************************************************************************* ** *******************************************************************************/ function childElementChangedCallback() { updateUsedGroupByFieldNames(modalPivotTableDefinition); updateUsedValueFieldNames(modalPivotTableDefinition); validateForm(); forceUpdate(); } /******************************************************************************* ** *******************************************************************************/ function addValue() { if (!modalPivotTableDefinition.values) { modalPivotTableDefinition.values = []; } modalPivotTableDefinition.values.push(new PivotTableValue()); validateForm(); forceUpdate(); } /******************************************************************************* ** *******************************************************************************/ function removeValue(index: number) { modalPivotTableDefinition.values.splice(index, 1); validateForm(); forceUpdate(); } /******************************************************************************* ** *******************************************************************************/ function updateUsedGroupByFieldNames(ptd: PivotTableDefinition = pivotTableDefinition) { const usedFieldNames: string[] = []; for (let i = 0; i < ptd?.rows?.length; i++) { usedFieldNames.push(ptd?.rows[i].fieldName); } for (let i = 0; i < ptd?.columns?.length; i++) { usedFieldNames.push(ptd?.columns[i].fieldName); } setUsedGroupByFieldNames(usedFieldNames); } /******************************************************************************* ** *******************************************************************************/ function updateUsedValueFieldNames(ptd: PivotTableDefinition = pivotTableDefinition) { const usedFieldNames: string[] = []; for (let i = 0; i < ptd?.values?.length; i++) { usedFieldNames.push(ptd?.values[i].fieldName); } setUsedValueByFieldNames(usedFieldNames); } /******************************************************************************* ** *******************************************************************************/ function updateAvailableFieldNames(columns: QQueryColumns) { const fieldNames: string[] = []; for (let i = 0; i < columns?.columns?.length; i++) { if (columns.columns[i].isVisible) { fieldNames.push(columns.columns[i].name); } } setAvailableFieldNames(fieldNames); } /******************************************************************************* ** *******************************************************************************/ function renderOneValue(value: PivotTableValue, index: number) { if (!isEditable) { const selectedField = getSelectedFieldForAutoComplete(tableMetaData, 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; }; const handleFunctionChange = (event: any, newValue: any, reason: string) => { value.function = newValue ? newValue.id : null; }; 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} /> ); } /******************************************************************************* ** drag & drop callback to move one of the pivot-table group-bys (rows/columns) *******************************************************************************/ const moveGroupBy = useCallback((rowsOrColumns: "rows" | "columns", dragIndex: number, hoverIndex: number) => { const array = modalPivotTableDefinition[rowsOrColumns]; const dragItem = array[dragIndex]; array.splice(dragIndex, 1); array.splice(hoverIndex, 0, dragItem); forceUpdate(); }, [modalPivotTableDefinition]); /******************************************************************************* ** drag & drop callback to move one of the pivot-table values *******************************************************************************/ const moveValue = useCallback((dragIndex: number, hoverIndex: number) => { const array = modalPivotTableDefinition.values; const dragItem = array[dragIndex]; array.splice(dragIndex, 1); array.splice(hoverIndex, 0, dragItem); forceUpdate(); }, [modalPivotTableDefinition]); const noTable = (tableMetaData == null); const noColumns = (!availableFieldNames || availableFieldNames.length == 0); const selectTableFirstTooltipTitle = noTable ? "You must select a table before you can set up your pivot table" : null; const selectColumnsFirstTooltipTitle = noColumns ? "You must set up your report's Columns before you can set up your Pivot Table" : null; const editPopupDisabled = noTable || noColumns; ///////////////////////////////////////////////////////////// // add toggle component to widget header for editable mode // ///////////////////////////////////////////////////////////// const labelAdditionalElementsRight: JSX.Element[] = []; if (isEditable) { labelAdditionalElementsRight.push( enabled} onClickCallback={toggleEnabled} />); } /******************************************************************************* ** render a group-by (row or column) *******************************************************************************/ const renderGroupBy = useCallback((groupBy: PivotTableGroupBy, rowsOrColumns: "rows" | "columns", index: number, forModal: boolean) => { return ( ); }, [tableMetaData, usedGroupByFieldNames, availableFieldNames], ); /******************************************************************************* ** render a pivot-table value (row or column) *******************************************************************************/ const renderValue = useCallback((value: PivotTableValue, index: number, forModal: boolean) => { return ( ); }, [tableMetaData, usedGroupByFieldNames, availableFieldNames], ); /******************************************************************************* ** *******************************************************************************/ function openEditor() { if (recordValues["tableName"]) { setModalPivotTableDefinition(Object.assign({}, pivotTableDefinition)); setModalOpen(true); setAttemptedSubmit(false); } } /******************************************************************************* ** *******************************************************************************/ function closeEditor(event?: {}, reason?: "backdropClick" | "escapeKeyDown") { if (reason == "backdropClick" || reason == "escapeKeyDown") { return; } setModalOpen(false); } /******************************************************************************* ** *******************************************************************************/ function renderGroupBys(forModal: boolean, rowsOrColumns: "rows" | "columns") { const ptd = forModal ? modalPivotTableDefinition : pivotTableDefinition; return <>
{rowsOrColumns == "rows" ? "Rows" : "Columns"}
{ tableMetaData && (
{ptd[rowsOrColumns]?.map((groupBy, i) => renderGroupBy(groupBy, rowsOrColumns, i, forModal))}
) }
{ (forModal || (isEditable && !ptd[rowsOrColumns]?.length)) && } { !isEditable && !forModal && !ptd[rowsOrColumns]?.length && Your pivot table has no {rowsOrColumns}. } ; } /******************************************************************************* ** *******************************************************************************/ function renderValues(forModal: boolean) { const ptd = forModal ? modalPivotTableDefinition : pivotTableDefinition; return <>
Values
{ tableMetaData && (
{ptd?.values?.map((value, i) => renderValue(value, i, forModal))}
) }
{ (forModal || (isEditable && !ptd?.values?.length)) && } { !isEditable && !forModal && !ptd?.values?.length && Your pivot table has no values. } ; } /******************************************************************************* ** *******************************************************************************/ function validateForm(submitting: boolean = false) { /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // if this isn't a call from the on-submit handler, and we haven't previously attempted a submit, then return w/o setting any alerts // // this is like a version of considering "touched"... // /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// if (!submitting && !attemptedSubmit) { return; } let missingValues = 0; for (let i = 0; i < modalPivotTableDefinition?.rows?.length; i++) { if (!modalPivotTableDefinition.rows[i].fieldName) { missingValues++; } } for (let i = 0; i < modalPivotTableDefinition?.columns?.length; i++) { if (!modalPivotTableDefinition.columns[i].fieldName) { missingValues++; } } for (let i = 0; i < modalPivotTableDefinition?.values?.length; i++) { if (!modalPivotTableDefinition.values[i].fieldName) { missingValues++; } if (!modalPivotTableDefinition.values[i].function) { missingValues++; } } if (missingValues == 0) { setErrorAlert(null); //////////////////////////////////////////////////////////////////////////////////// // this is to catch the case of - user attempted to submit, and there were errors // // now they've fixed 'em - so go back to a 'clean' state - so if they add more // // boxes, they won't immediately show errors, until a re-submit // //////////////////////////////////////////////////////////////////////////////////// if (attemptedSubmit) { setAttemptedSubmit(false); } return (false); } setErrorAlert(`Missing value in ${missingValues} field${missingValues == 1 ? "" : "s"}.`); return (true); } /******************************************************************************* ** *******************************************************************************/ function saveClicked() { setAttemptedSubmit(true); if (validateForm(true)) { return; } if (!onSaveCallback) { console.log("onSaveCallback was not defined"); return; } setPivotTableDefinition(Object.assign({}, modalPivotTableDefinition)); updateUsedGroupByFieldNames(modalPivotTableDefinition); updateUsedValueFieldNames(modalPivotTableDefinition); onSaveCallback({pivotTableJson: JSON.stringify(modalPivotTableDefinition)}); closeEditor(); } //////////// // render // //////////// return ( { { enabled && { showHelp("sectionSubhead") && {getHelpContent("sectionSubhead")} } { isEditable && } } { (!enabled || !pivotTableDefinition) && !isEditable && Your report does not use a Pivot Table. } { enabled && pivotTableDefinition && <> {renderGroupBys(false, "rows")} {renderGroupBys(false, "columns")} {renderValues(false)} { modalOpen && closeEditor(event, reason)}>

Edit Pivot Table

{ showHelp("modalSubheader") && {getHelpContent("modalSubheader")} } { errorAlert && error_outline} color="error" onClose={() => setErrorAlert(null)}>{errorAlert} } {renderGroupBys(true, "rows")} {renderGroupBys(true, "columns")} {renderValues(true)}
} }
}
); } /* this was a rough-draft of what a preview of a pivot could look like...
Preview
{ pivotTableDefinition?.columns?.map((column, i) => ( )) } { pivotTableDefinition?.values?.map((value, i) => ( )) } { pivotTableDefinition?.rows?.map((row, i) => ( )) }
Column Labels
{column.fieldName}
Row Labels{value.function} of {value.fieldName}
{row.fieldName}
*/