diff --git a/src/qqq/components/widgets/Widget.tsx b/src/qqq/components/widgets/Widget.tsx index 0532363..ccf0d45 100644 --- a/src/qqq/components/widgets/Widget.tsx +++ b/src/qqq/components/widgets/Widget.tsx @@ -165,33 +165,38 @@ export class HeaderIcon extends LabelComponent /******************************************************************************* - ** + ** a link (actually a button) for in a widget's header *******************************************************************************/ -export class HeaderLinkButton extends LabelComponent +interface HeaderLinkButtonComponentProps { label: string; onClickCallback: () => void; - - - constructor(label: string, onClickCallback: () => void) - { - super(); - this.label = label; - this.onClickCallback = onClickCallback; - } - - render = (args: LabelComponentRenderArgs): JSX.Element => - { - return ( - - ); - }; + disabled?: boolean; + disabledTooltip?: string; } +HeaderLinkButtonComponent.defaultProps = { + disabled: false, + disabledTooltip: null +}; + +export function HeaderLinkButtonComponent({label, onClickCallback, disabled, disabledTooltip}: HeaderLinkButtonComponentProps): JSX.Element +{ + return ( + + + + + + ); +} + + + /******************************************************************************* ** @@ -205,8 +210,6 @@ interface HeaderToggleComponentProps export function HeaderToggleComponent({label, getValue, onClickCallback}: HeaderToggleComponentProps): JSX.Element { - console.log(`@dk in HTComponent, getValue(): ${getValue()}`); - const onClick = () => { onClickCallback(); diff --git a/src/qqq/components/widgets/misc/PivotTableGroupByElement.tsx b/src/qqq/components/widgets/misc/PivotTableGroupByElement.tsx new file mode 100644 index 0000000..56454aa --- /dev/null +++ b/src/qqq/components/widgets/misc/PivotTableGroupByElement.tsx @@ -0,0 +1,204 @@ +/* + * 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 Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Icon from "@mui/material/Icon"; +import type {Identifier, XYCoord} from "dnd-core"; +import colors from "qqq/assets/theme/base/colors"; +import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; +import {DragItemTypes, fieldAutoCompleteTextFieldSX, getSelectedFieldForAutoComplete, xIconButtonSX} from "qqq/components/widgets/misc/PivotTableSetupWidget"; +import {PivotTableDefinition, PivotTableGroupBy} from "qqq/models/misc/PivotTableDefinitionModels"; +import React, {FC, useRef} from "react"; +import {useDrag, useDrop} from "react-dnd"; + + +/******************************************************************************* + ** component props + *******************************************************************************/ +export interface PivotTableGroupByElementProps +{ + id: string; + index: number; + dragCallback: (rowsOrColumns: "rows" | "columns", dragIndex: number, hoverIndex: number) => void; + metaData: QInstance; + tableMetaData: QTableMetaData; + pivotTableDefinition: PivotTableDefinition; + usedGroupByFieldNames: string[]; + availableFieldNames: string[]; + isEditable: boolean; + groupBy: PivotTableGroupBy; + rowsOrColumns: "rows" | "columns"; + callback: () => void; +} + + +/******************************************************************************* + ** item to support react-dnd + *******************************************************************************/ +interface DragItem +{ + index: number; + id: string; + type: string; +} + +/******************************************************************************* + ** + *******************************************************************************/ +export const PivotTableGroupByElement: FC = ({id, index, dragCallback, rowsOrColumns, metaData, tableMetaData, pivotTableDefinition, groupBy, usedGroupByFieldNames, availableFieldNames, isEditable, callback}) => +{ + //////////////////////////////////////////////////////////////////////////// + // credit: https://react-dnd.github.io/react-dnd/examples/sortable/simple // + //////////////////////////////////////////////////////////////////////////// + const ref = useRef(null); + const [{handlerId}, drop] = useDrop( + { + accept: rowsOrColumns == "rows" ? DragItemTypes.ROW : DragItemTypes.COLUMN, + collect(monitor) + { + return { + handlerId: monitor.getHandlerId(), + }; + }, + hover(item: DragItem, monitor) + { + if (!ref.current) + { + return; + } + const dragIndex = item.index; + const hoverIndex = index; + + // Don't replace items with themselves + if (dragIndex === hoverIndex) + { + return; + } + + // Determine rectangle on screen + const hoverBoundingRect = ref.current?.getBoundingClientRect(); + + // Get vertical middle + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + + // Determine mouse position + const clientOffset = monitor.getClientOffset(); + + // Get pixels to the top + const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top; + + // Only perform the move when the mouse has crossed half of the items height + // When dragging downwards, only move when the cursor is below 50% + // When dragging upwards, only move when the cursor is above 50% + + // Dragging downwards + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) + { + return; + } + + // Dragging upwards + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) + { + return; + } + + // Time to actually perform the action + dragCallback(rowsOrColumns, dragIndex, hoverIndex); + + // Note: we're mutating the monitor item here! Generally it's better to avoid mutations, + // but it's good here for the sake of performance to avoid expensive index searches. + item.index = hoverIndex; + }, + }); + + const [{isDragging}, drag, preview] = useDrag({ + type: rowsOrColumns == "rows" ? DragItemTypes.ROW : DragItemTypes.COLUMN, + item: () => + { + return {id, index}; + }, + collect: (monitor: any) => ({ + isDragging: monitor.isDragging(), + }), + }); + + + /******************************************************************************* + ** + *******************************************************************************/ + const handleFieldChange = (event: any, newValue: any, reason: string) => + { + groupBy.fieldName = newValue ? newValue.fieldName : null; + callback(); + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + function removeGroupBy(index: number, rowsOrColumns: "rows" | "columns") + { + pivotTableDefinition[rowsOrColumns].splice(index, 1); + callback(); + } + + if (!isEditable) + { + const selectedField = getSelectedFieldForAutoComplete(tableMetaData, groupBy.fieldName); + if (selectedField) + { + const label = selectedField.table.name == tableMetaData.name ? selectedField.field.label : selectedField.table.label + ": " + selectedField.field.label; + return ({label}); + } + + return (); + } + + preview(drop(ref)); + + return ( + + drag_indicator + + + + + + + + ); +}; diff --git a/src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx b/src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx index 336e2dd..c7aa642 100644 --- a/src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx +++ b/src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx @@ -30,19 +30,88 @@ 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 QContext from "QContext"; import colors from "qqq/assets/theme/base/colors"; 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 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, {useEffect, useReducer, useState} from "react"; +import React, {useCallback, useContext, useEffect, useReducer, useState} from "react"; +import {DndProvider} from "react-dnd"; +import {HTML5Backend} from "react-dnd-html5-backend"; -/////////////////////////////////////////////////////////////////////////////// -// 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(); +export const DragItemTypes = + { + ROW: "row", + COLUMN: "column", + VALUE: "value" + }; +export 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}, + }; + +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; @@ -51,71 +120,14 @@ interface PivotTableSetupWidgetProps onSaveCallback?: (values: { [name: string]: any }) => void; } + +/******************************************************************************* + ** default values for props + *******************************************************************************/ 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(); @@ -134,54 +146,62 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor const [pivotTableDefinition, setPivotTableDefinition] = useState(null as PivotTableDefinition); const [usedGroupByFieldNames, setUsedGroupByFieldNames] = useState([] as string[]); + const [availableFieldNames, setAvailableFieldNames] = useState([] as string[]); + const {helpHelpActive} = useContext(QContext); ////////////////// // initial load // ////////////////// useEffect(() => { - (async () => + if (!pivotTableDefinition) { - if (!pivotTableDefinition) + let originalPivotTableDefinition = recordValues["pivotTableJson"] && JSON.parse(recordValues["pivotTableJson"]) as PivotTableDefinition; + if (originalPivotTableDefinition) { - 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); + 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); + } + + if(recordValues["columnsJson"]) + { + updateAvailableFieldNames(JSON.parse(recordValues["columnsJson"]) as QQueryColumns) + } + + (async () => + { setMetaData(await qController.loadMetaData()); })(); }); @@ -202,6 +222,27 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor }, [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 ; + } + + /******************************************************************************* ** *******************************************************************************/ @@ -241,9 +282,8 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor /******************************************************************************* ** *******************************************************************************/ - function removeGroupBy(index: number, rowsOrColumns: "rows" | "columns") + function groupByChangedCallback() { - pivotTableDefinition[rowsOrColumns].splice(index, 1); onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); updateUsedGroupByFieldNames(); forceUpdate(); @@ -277,130 +317,41 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor } - 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() + function updateUsedGroupByFieldNames(ptd: PivotTableDefinition = pivotTableDefinition) { - const hiddenFieldNames: string[] = []; + const usedFieldNames: string[] = []; - for (let i = 0; i < pivotTableDefinition?.rows?.length; i++) + for (let i = 0; i < ptd?.rows?.length; i++) { - hiddenFieldNames.push(pivotTableDefinition?.rows[i].fieldName); + usedFieldNames.push(ptd?.rows[i].fieldName); } - for (let i = 0; i < pivotTableDefinition?.columns?.length; i++) + for (let i = 0; i < ptd?.columns?.length; i++) { - hiddenFieldNames.push(pivotTableDefinition?.columns[i].fieldName); + usedFieldNames.push(ptd?.columns[i].fieldName); } - setUsedGroupByFieldNames(hiddenFieldNames); + setUsedGroupByFieldNames(usedFieldNames); } /******************************************************************************* ** *******************************************************************************/ - function getSelectedFieldForAutoComplete(fieldName: string) + function updateAvailableFieldNames(columns: QQueryColumns) { - if (fieldName) + const fieldNames: string[] = []; + for (let i = 0; i < columns?.columns?.length; i++) { - let [field, fieldTable] = FilterUtils.getField(tableMetaData, fieldName); - if (field && fieldTable) + if(columns.columns[i].isVisible) { - return ({field: field, table: fieldTable, fieldName: fieldName}); + fieldNames.push(columns.columns[i].name); } } - - 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 - - - - - - - - ); + setAvailableFieldNames(fieldNames); } @@ -409,12 +360,12 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor *******************************************************************************/ function renderOneValue(value: PivotTableValue, index: number) { - if(!isEditable) + if (!isEditable) { - const selectedField = getSelectedFieldForAutoComplete(value.fieldName); - if(selectedField && value.function) + 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 + const label = selectedField.table.name == tableMetaData.name ? selectedField.field.label : selectedField.table.label + ": " + selectedField.field.label; return ({pivotTableFunctionLabels[value.function]} of {label}); } @@ -441,8 +392,8 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor const label = "" + pivotTableFunctionLabels[pivotTableFunctionKey]; const option = {id: pivotTableFunctionKey, label: label}; functionOptions.push(option); - - if(option.id == value.function) + + if (option.id == value.function) { defaultFunctionValue = option; } @@ -462,7 +413,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor metaData={metaData} tableMetaData={tableMetaData} handleFieldChange={handleFieldChange} - defaultValue={getSelectedFieldForAutoComplete(value.fieldName)} + defaultValue={getSelectedFieldForAutoComplete(tableMetaData, value.fieldName)} /> @@ -490,6 +441,36 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor } + /******************************************************************************* + ** 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 = pivotTableDefinition[rowsOrColumns]; + const dragItem = array[dragIndex]; + array.splice(dragIndex, 1); + array.splice(hoverIndex, 0, dragItem); + + onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); + forceUpdate(); + }, [pivotTableDefinition]); + + + /******************************************************************************* + ** drag & drop callback to move one of the pivot-table values + *******************************************************************************/ + const moveValue = useCallback((dragIndex: number, hoverIndex: number) => + { + const array = pivotTableDefinition.values; + const dragItem = array[dragIndex]; + array.splice(dragIndex, 1); + array.splice(hoverIndex, 0, dragItem); + + onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); + forceUpdate(); + }, [pivotTableDefinition]); + + ///////////////////////////////////////////////////////////// // add toggle component to widget header for editable mode // ///////////////////////////////////////////////////////////// @@ -501,19 +482,77 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor const selectTableFirstTooltipTitle = tableMetaData ? null : "You must select a table before you can set up a pivot table"; + + /******************************************************************************* + ** render a group-by (row or column) + *******************************************************************************/ + const renderGroupBy = useCallback( + (groupBy: PivotTableGroupBy, rowsOrColumns: "rows" | "columns", index: number) => + { + return ( + + ); + }, + [tableMetaData, usedGroupByFieldNames, availableFieldNames], + ); + + + /******************************************************************************* + ** render a pivot-table value (row or column) + *******************************************************************************/ + const renderValue = useCallback( + (value: PivotTableValue, index: number) => + { + return ( + + ); + }, + [tableMetaData, usedGroupByFieldNames, availableFieldNames], + ); + + return ( {enabled && pivotTableDefinition && - - + + { + showHelp("sectionSubhead") && + + {getHelpContent("sectionSubhead")} + + } +
Rows
{ - tableMetaData && pivotTableDefinition.rows?.map((row: PivotTableGroupBy, index: number) => - ( - {renderOneGroupBy(row, index, "rows")} - )) + tableMetaData && (
{pivotTableDefinition?.rows?.map((row, i) => renderGroupBy(row, "rows", i))}
) }
{ @@ -530,10 +569,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor
Columns
{ - tableMetaData && pivotTableDefinition.columns?.map((column: PivotTableGroupBy, index: number) => - ( - {renderOneGroupBy(column, index, "columns")} - )) + tableMetaData && (
{pivotTableDefinition?.columns?.map((column, i) => renderGroupBy(column, "columns", i))}
) }
{ @@ -550,10 +586,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor
Values
{ - tableMetaData && pivotTableDefinition.values?.map((value: PivotTableValue, index: number) => - ( - {renderOneValue(value, index)} - )) + tableMetaData && (
{pivotTableDefinition?.values?.map((value, i) => renderValue(value, i))}
) }
{ @@ -567,7 +600,44 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor
-
+ {/* + +
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}
+
+ */} + }
); } diff --git a/src/qqq/components/widgets/misc/PivotTableValueElement.tsx b/src/qqq/components/widgets/misc/PivotTableValueElement.tsx new file mode 100644 index 0000000..38c6704 --- /dev/null +++ b/src/qqq/components/widgets/misc/PivotTableValueElement.tsx @@ -0,0 +1,279 @@ +/* + * 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 Autocomplete from "@mui/material/Autocomplete"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Icon from "@mui/material/Icon"; +import TextField from "@mui/material/TextField"; +import type {Identifier, XYCoord} from "dnd-core"; +import colors from "qqq/assets/theme/base/colors"; +import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; +import {DragItemTypes, fieldAutoCompleteTextFieldSX, getSelectedFieldForAutoComplete, xIconButtonSX} from "qqq/components/widgets/misc/PivotTableSetupWidget"; +import {PivotTableDefinition, PivotTableFunction, pivotTableFunctionLabels, PivotTableValue} from "qqq/models/misc/PivotTableDefinitionModels"; +import React, {FC, useRef} from "react"; +import {useDrag, useDrop} from "react-dnd"; + + +/******************************************************************************* + ** component props + *******************************************************************************/ +export interface PivotTableValueElementProps +{ + id: string; + index: number; + dragCallback: (dragIndex: number, hoverIndex: number) => void; + metaData: QInstance; + tableMetaData: QTableMetaData; + pivotTableDefinition: PivotTableDefinition; + availableFieldNames: string[]; + isEditable: boolean; + value: PivotTableValue; + callback: () => void; +} + + +/******************************************************************************* + ** item to support react-dnd + *******************************************************************************/ +interface DragItem +{ + index: number; + id: string; + type: string; +} + + +/******************************************************************************* + ** Element to render 1 pivot-table value. + *******************************************************************************/ +export const PivotTableValueElement: FC = ({id, index, dragCallback, metaData, tableMetaData, pivotTableDefinition, availableFieldNames, value, isEditable, callback}) => +{ + //////////////////////////////////////////////////////////////////////////// + // credit: https://react-dnd.github.io/react-dnd/examples/sortable/simple // + //////////////////////////////////////////////////////////////////////////// + const ref = useRef(null); + const [{handlerId}, drop] = useDrop( + { + accept: DragItemTypes.VALUE, + collect(monitor) + { + return { + handlerId: monitor.getHandlerId(), + }; + }, + hover(item: DragItem, monitor) + { + if (!ref.current) + { + return; + } + const dragIndex = item.index; + const hoverIndex = index; + + // Don't replace items with themselves + if (dragIndex === hoverIndex) + { + return; + } + + // Determine rectangle on screen + const hoverBoundingRect = ref.current?.getBoundingClientRect(); + + // Get vertical middle + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + + // Determine mouse position + const clientOffset = monitor.getClientOffset(); + + // Get pixels to the top + const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top; + + // Only perform the move when the mouse has crossed half of the items height + // When dragging downwards, only move when the cursor is below 50% + // When dragging upwards, only move when the cursor is above 50% + + // Dragging downwards + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) + { + return; + } + + // Dragging upwards + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) + { + return; + } + + // Time to actually perform the action + dragCallback(dragIndex, hoverIndex); + + // Note: we're mutating the monitor item here! Generally it's better to avoid mutations, + // but it's good here for the sake of performance to avoid expensive index searches. + item.index = hoverIndex; + }, + }); + + const [{isDragging}, drag] = useDrag({ + type: DragItemTypes.VALUE, + item: () => + { + return {id, index}; + }, + collect: (monitor: any) => ({ + isDragging: monitor.isDragging(), + }), + }); + + + /******************************************************************************* + ** event handler for user selecting a field + *******************************************************************************/ + const handleFieldChange = (event: any, newValue: any, reason: string) => + { + value.fieldName = newValue ? newValue.fieldName : null; + callback(); + }; + + + /******************************************************************************* + ** event handler for user selecting a function + *******************************************************************************/ + const handleFunctionChange = (event: any, newValue: any, reason: string) => + { + value.function = newValue ? newValue.id : null; + callback(); + }; + + + /******************************************************************************* + ** event handler for clicking remove button + *******************************************************************************/ + function removeValue(index: number) + { + pivotTableDefinition.values.splice(index, 1); + callback(); + } + + + ///////////////////////////////////////////////////////////////////// + // if we're not on an edit screen, return a simpler read-only view // + ///////////////////////////////////////////////////////////////////// + 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 (); + } + + /////////////////////////////////////////////////////////////////////////////// + // figure out functions to display in drop down, plus selected/default value // + /////////////////////////////////////////////////////////////////////////////// + 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; + } + } + + drag(drop(ref)); + + /* + return ( + + drag_indicator + + + + + + + + ); + */ + + 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} + /> + + + + + ); + +}; diff --git a/src/qqq/components/widgets/misc/ReportSetupWidget.tsx b/src/qqq/components/widgets/misc/ReportSetupWidget.tsx index 510c4a7..39eb8f6 100644 --- a/src/qqq/components/widgets/misc/ReportSetupWidget.tsx +++ b/src/qqq/components/widgets/misc/ReportSetupWidget.tsx @@ -28,12 +28,13 @@ import Box from "@mui/material/Box"; import Card from "@mui/material/Card"; import Link from "@mui/material/Link"; import Modal from "@mui/material/Modal"; +import Tooltip from "@mui/material/Tooltip/Tooltip"; import QContext from "QContext"; import colors from "qqq/assets/theme/base/colors"; import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent"; import AdvancedQueryPreview from "qqq/components/query/AdvancedQueryPreview"; -import Widget, {HeaderLinkButton, LabelComponent} from "qqq/components/widgets/Widget"; +import Widget, {HeaderLinkButtonComponent} from "qqq/components/widgets/Widget"; import QQueryColumns, {Column} from "qqq/models/query/QQueryColumns"; import RecordQuery from "qqq/pages/records/query/RecordQuery"; import Client from "qqq/utils/qqq/Client"; @@ -64,9 +65,14 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal const [alertContent, setAlertContent] = useState(null as string); + const {helpHelpActive} = useContext(QContext); + const recordQueryRef = useRef(); + ///////////////////////////// + // load values from record // + ///////////////////////////// let queryFilter = recordValues["queryFilterJson"] && JSON.parse(recordValues["queryFilterJson"]) as QQueryFilter; if(!queryFilter) { @@ -79,6 +85,9 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal columns = new QQueryColumns(); } + ////////////////////////////////////////////////////////////////////// + // load tableMetaData initially, and if/when selected table changes // + ////////////////////////////////////////////////////////////////////// useEffect(() => { if (recordValues["tableName"] && (tableMetaData == null || tableMetaData.name != recordValues["tableName"])) @@ -101,10 +110,6 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal { setModalOpen(true); } - else - { - setAlertContent("You must select a table before you can edit filters and columns") - } } @@ -140,12 +145,6 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal setModalOpen(false); } - const labelAdditionalComponentsRight: LabelComponent[] = [] - if(isEditable) - { - labelAdditionalComponentsRight.push(new HeaderLinkButton("Edit Filters and Columns", openEditor)) - } - /******************************************************************************* ** @@ -199,18 +198,45 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal return (false); } - //////////////////// - // load help text // - //////////////////// - const helpRoles = ["ALL_SCREENS"] - const key = "slot:reportSetupSubheader"; // todo - ?? - const {helpHelpActive} = useContext(QContext); - const showHelp = helpHelpActive || hasHelpContent(widgetMetaData?.helpContent?.get(key), helpRoles); - const formattedHelpContent = ; - // const formattedHelpContent = "Add and edit filter and columns for your report." + const helpRoles = isEditable ? [recordValues["id"] ? "EDIT_SCREEN" : "INSERT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"] : ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"]; - return ( + /******************************************************************************* + ** + *******************************************************************************/ + function showHelp(slot: string) + { + return (helpHelpActive || hasHelpContent(widgetMetaData?.helpContent?.get(slot), helpRoles)); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function getHelpContent(slot: string) + { + const key = `widget:${widgetMetaData.name};slot:${slot}`; + return ; + } + + ///////////////////////////////////////////////// + // add link to widget header for opening modal // + ///////////////////////////////////////////////// + const selectTableFirstTooltipTitle = tableMetaData ? null : "You must select a table before you can set up your report filters and columns"; + const labelAdditionalElementsRight: JSX.Element[] = [] + if(isEditable) + { + labelAdditionalElementsRight.push() + } + + + return ( + { + showHelp("sectionSubhead") && + + {getHelpContent("sectionSubhead")} + + } setAlertContent(null)}>{alertContent} @@ -224,7 +250,10 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal !mayShowQueryPreview() && { - isEditable && + Add Filters + isEditable && + + + Add Filters + } { !isEditable && Your report has no filters. @@ -243,7 +272,10 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal !mayShowColumnsPreview() && { - isEditable && + Add Columns + isEditable && + + + Add Columns + } { !isEditable && Your report has no filters. @@ -260,9 +292,9 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal

Edit Filters and Columns

{ - showHelp && + showHelp("modalSubheader") && - {formattedHelpContent} + {getHelpContent("modalSubheader")} } { diff --git a/src/qqq/models/misc/PivotTableDefinitionModels.ts b/src/qqq/models/misc/PivotTableDefinitionModels.ts new file mode 100644 index 0000000..3eeffc4 --- /dev/null +++ b/src/qqq/models/misc/PivotTableDefinitionModels.ts @@ -0,0 +1,115 @@ +/* + * 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 . + */ + + +/******************************************************************************* + ** put a unique key value in all the pivot table group-by and value objects, + ** to help react rendering be sane. + *******************************************************************************/ +export class PivotObjectKey +{ + private static value = new Date().getTime(); + + static next(): number + { + return PivotObjectKey.value++ + } +} + + +/******************************************************************************* + ** Full definition of pivot table + *******************************************************************************/ +export class PivotTableDefinition +{ + rows: PivotTableGroupBy[]; + columns: PivotTableGroupBy[]; + values: PivotTableValue[]; +} + + +/******************************************************************************* + ** A field that the pivot table is grouped by, either as a row or column + *******************************************************************************/ +export class PivotTableGroupBy +{ + fieldName: string; + key: number; + + constructor() + { + this.key = PivotObjectKey.next() + } +} + + +/******************************************************************************* + ** A field & function that serves as the computed values in the pivot table + *******************************************************************************/ +export class PivotTableValue +{ + fieldName: string; + function: PivotTableFunction; + + key: number; + + constructor() + { + this.key = PivotObjectKey.next() + } +} + + +/******************************************************************************* + ** Functions that can be appplied to pivot table values + *******************************************************************************/ +export 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", +} + +////////////////////////////////////// +// labels for pivot table functions // +////////////////////////////////////// +export 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" + }; diff --git a/src/qqq/pages/records/view/RecordView.tsx b/src/qqq/pages/records/view/RecordView.tsx index d586848..7b24602 100644 --- a/src/qqq/pages/records/view/RecordView.tsx +++ b/src/qqq/pages/records/view/RecordView.tsx @@ -845,7 +845,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element return ( - + diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index b327e62..dd3b82a 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -658,3 +658,9 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } { border: none; } + +.entityForm h5, +.recordView h5 +{ + font-weight: 500; +} \ No newline at end of file