From 4c9c9ab80e95e1f289d2f505a068425728006784 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 9 Apr 2024 15:51:27 -0500 Subject: [PATCH 01/22] CE-1115 - Break this component out into its own ... component. --- .../components/query/AdvancedQueryPreview.tsx | 153 ++++++++++++++++++ .../query/BasicAndAdvancedQueryControls.tsx | 79 +-------- 2 files changed, 158 insertions(+), 74 deletions(-) create mode 100644 src/qqq/components/query/AdvancedQueryPreview.tsx diff --git a/src/qqq/components/query/AdvancedQueryPreview.tsx b/src/qqq/components/query/AdvancedQueryPreview.tsx new file mode 100644 index 0000000..1ea81de --- /dev/null +++ b/src/qqq/components/query/AdvancedQueryPreview.tsx @@ -0,0 +1,153 @@ +/* + * 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 {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import Box from "@mui/material/Box"; +import colors from "qqq/assets/theme/base/colors"; +import {validateCriteria} from "qqq/components/query/FilterCriteriaRow"; +import XIcon from "qqq/components/query/XIcon"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; +import React, {useState} from "react"; + +interface AdvancedQueryPreviewProps +{ + tableMetaData: QTableMetaData; + queryFilter: QQueryFilter; + isEditable: boolean; + isQueryTooComplex: boolean; + removeCriteriaByIndexCallback: (index: number) => void; +} + +/******************************************************************************* + ** Box shown on query screen (and more??) to preview what a query looks like, + ** as an "advanced" style/precursor-to-writing-your-own-query thing. + *******************************************************************************/ +export default function AdvancedQueryPreview({tableMetaData, queryFilter, isEditable, isQueryTooComplex, removeCriteriaByIndexCallback}: AdvancedQueryPreviewProps): JSX.Element +{ + const [mouseOverElement, setMouseOverElement] = useState(null as string); + + + /******************************************************************************* + ** + *******************************************************************************/ + function handleMouseOverElement(name: string) + { + setMouseOverElement(name); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function handleMouseOutElement() + { + setMouseOverElement(null); + } + + + + /******************************************************************************* + ** format the current query as a string for showing on-screen as a preview. + *******************************************************************************/ + const queryToAdvancedString = (thisQueryFilter: QQueryFilter) => + { + if (queryFilter == null || !queryFilter.criteria) + { + return (); + } + + let counter = 0; + + return ( + + {thisQueryFilter.criteria?.map((criteria, i) => + { + const {criteriaIsValid} = validateCriteria(criteria, null); + if (criteriaIsValid) + { + counter++; + return ( + handleMouseOverElement(`queryPreview-${i}`)} onMouseOut={() => handleMouseOutElement()}> + {counter > 1 ? {thisQueryFilter.booleanOperator}  : } + {FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)} + {isEditable && !isQueryTooComplex && ( + mouseOverElement == `queryPreview-${i}` && + removeCriteriaByIndexCallback(i)} /> + )} + {counter > 1 && i == thisQueryFilter.criteria?.length - 1 && thisQueryFilter.subFilters?.length > 0 ? {thisQueryFilter.booleanOperator}  : } + + ); + } + else + { + return (); + } + })} + + {thisQueryFilter.subFilters?.length > 0 && (thisQueryFilter.subFilters.map((filter: QQueryFilter, j) => + { + return ( + + {j > 0 ? {thisQueryFilter.booleanOperator}  : } + ( + {queryToAdvancedString(filter)} + ) + + ); + }))} + + ); + }; + + const moreSX = isEditable ? + { + borderTop: `1px solid ${colors.grayLines.main}`, + boxShadow: "inset 0px 0px 4px 2px #EFEFED", + borderRadius: "0 0 0.75rem 0.75rem", + } : + { + borderRadius: "0.75rem", + border: `1px solid ${colors.grayLines.main}`, + } + + return ( + + { + + + {queryToAdvancedString(queryFilter)} + + + } + + ) +} diff --git a/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx index 563d002..8a2ae6e 100644 --- a/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx +++ b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx @@ -44,11 +44,13 @@ import {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro"; import QContext from "QContext"; import colors from "qqq/assets/theme/base/colors"; import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; +import AdvancedQueryPreview from "qqq/components/query/AdvancedQueryPreview"; import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; import FieldListMenu from "qqq/components/query/FieldListMenu"; import {validateCriteria} from "qqq/components/query/FilterCriteriaRow"; import QuickFilter, {quickFilterButtonStyles} from "qqq/components/query/QuickFilter"; import XIcon from "qqq/components/query/XIcon"; +import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; import TableUtils from "qqq/utils/qqq/TableUtils"; import React, {forwardRef, useContext, useImperativeHandle, useReducer, useState} from "react"; @@ -75,6 +77,8 @@ interface BasicAndAdvancedQueryControlsProps ///////////////////////////////////////////////////////////////////////////////////////////// queryFilterJSON: string; + queryScreenUsage: QueryScreenUsage; + mode: string; setMode: (mode: string) => void; } @@ -397,60 +401,6 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo }; - /******************************************************************************* - ** format the current query as a string for showing on-screen as a preview. - *******************************************************************************/ - const queryToAdvancedString = (thisQueryFilter: QQueryFilter) => - { - if (queryFilter == null || !queryFilter.criteria) - { - return (); - } - - let counter = 0; - - return ( - - {thisQueryFilter.criteria?.map((criteria, i) => - { - const {criteriaIsValid} = validateCriteria(criteria, null); - if (criteriaIsValid) - { - counter++; - return ( - handleMouseOverElement(`queryPreview-${i}`)} onMouseOut={() => handleMouseOutElement()}> - {counter > 1 ? {thisQueryFilter.booleanOperator}  : } - {FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)} - {!isQueryTooComplex && ( - mouseOverElement == `queryPreview-${i}` && - removeCriteriaByIndex(i)} /> - )} - {counter > 1 && i == thisQueryFilter.criteria?.length - 1 && thisQueryFilter.subFilters?.length > 0 ? {thisQueryFilter.booleanOperator}  : } - - ); - } - else - { - return (); - } - })} - - {thisQueryFilter.subFilters?.length > 0 && (thisQueryFilter.subFilters.map((filter: QQueryFilter, j) => - { - return ( - - {j > 0 ? {thisQueryFilter.booleanOperator}  : } - ( - {queryToAdvancedString(filter)} - ) - - ); - }))} - - ); - }; - - /******************************************************************************* ** event handler for toggling between modes - basic & advanced. *******************************************************************************/ @@ -807,26 +757,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo {sortMenuComponent} - - { - - - {queryToAdvancedString(queryFilter)} - - - } - + } From e5e49a6db8e8bbb6ab6d52c015296c2ae08a855e Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 9 Apr 2024 15:54:12 -0500 Subject: [PATCH 02/22] CE-1115 - Add ReportSetupWidget and PivotTableSetupWidget --- .../components/widgets/DashboardWidgets.tsx | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/qqq/components/widgets/DashboardWidgets.tsx b/src/qqq/components/widgets/DashboardWidgets.tsx index 6c5ab04..0b51736 100644 --- a/src/qqq/components/widgets/DashboardWidgets.tsx +++ b/src/qqq/components/widgets/DashboardWidgets.tsx @@ -19,13 +19,13 @@ */ import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData"; +import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; import {Skeleton} from "@mui/material"; import Box from "@mui/material/Box"; import Grid from "@mui/material/Grid"; import Tab from "@mui/material/Tab"; import Tabs from "@mui/material/Tabs"; import parse from "html-react-parser"; -import React, {useContext, useEffect, useReducer, useState} from "react"; import QContext from "QContext"; import MDTypography from "qqq/components/legacy/MDTypography"; import TabPanel from "qqq/components/misc/TabPanel"; @@ -39,18 +39,21 @@ import CompositeWidget from "qqq/components/widgets/CompositeWidget"; import DataBagViewer from "qqq/components/widgets/misc/DataBagViewer"; import DividerWidget from "qqq/components/widgets/misc/Divider"; import FieldValueListWidget from "qqq/components/widgets/misc/FieldValueListWidget"; +import PivotTableSetupWidget from "qqq/components/widgets/misc/PivotTableSetupWidget"; import QuickSightChart from "qqq/components/widgets/misc/QuickSightChart"; import RecordGridWidget from "qqq/components/widgets/misc/RecordGridWidget"; +import ReportSetupWidget from "qqq/components/widgets/misc/ReportSetupWidget"; import ScriptViewer from "qqq/components/widgets/misc/ScriptViewer"; import StepperCard from "qqq/components/widgets/misc/StepperCard"; import USMapWidget from "qqq/components/widgets/misc/USMapWidget"; import ParentWidget from "qqq/components/widgets/ParentWidget"; import MultiStatisticsCard from "qqq/components/widgets/statistics/MultiStatisticsCard"; import StatisticsCard from "qqq/components/widgets/statistics/StatisticsCard"; -import Widget, {HeaderIcon, WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT, LabelComponent} from "qqq/components/widgets/Widget"; +import Widget, {HeaderIcon, LabelComponent, WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT} from "qqq/components/widgets/Widget"; import WidgetBlock from "qqq/components/widgets/WidgetBlock"; import ProcessRun from "qqq/pages/processes/ProcessRun"; import Client from "qqq/utils/qqq/Client"; +import React, {useContext, useEffect, useReducer, useState} from "react"; import TableWidget from "./tables/TableWidget"; @@ -61,6 +64,7 @@ interface Props widgetMetaDataList: QWidgetMetaData[]; tableName?: string; entityPrimaryKey?: string; + record?: QRecord; omitWrappingGridContainer: boolean; areChildren?: boolean; childUrlParams?: string; @@ -79,7 +83,7 @@ DashboardWidgets.defaultProps = { wrapWidgetsInTabPanels: false, }; -function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omitWrappingGridContainer, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels}: Props): JSX.Element +function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, record, omitWrappingGridContainer, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels}: Props): JSX.Element { const [widgetData, setWidgetData] = useState([] as any[]); const [widgetCounter, setWidgetCounter] = useState(0); @@ -248,6 +252,23 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit const widgetCount = widgetMetaDataList ? widgetMetaDataList.length : 0; + + /******************************************************************************* + ** helper function, to convert values from a QRecord values map to a regular old + ** js object + *******************************************************************************/ + function convertQRecordValuesFromMapToObject(record: QRecord): {[name: string]: any} + { + const rs: {[name: string]: any} = {}; + + if(record.values) + { + record.values.forEach((value, key) => rs[key] = value); + } + + return (rs); + } + const renderWidget = (widgetMetaData: QWidgetMetaData, i: number): JSX.Element => { const labelAdditionalComponentsRight: LabelComponent[] = []; @@ -546,6 +567,20 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit ) } + { + widgetMetaData.type === "reportSetup" && ( + widgetData && widgetData[i] && widgetData[i].queryParams && + + {}} /> + ) + } + { + widgetMetaData.type === "pivotTableSetup" && ( + widgetData && widgetData[i] && widgetData[i].queryParams && + + {}} /> + ) + } ); }; From 3558a91e7b77d3620a91a3b5a3a138c6be5b9ba5 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 9 Apr 2024 15:58:19 -0500 Subject: [PATCH 03/22] CE-1115 - support having the ReportSetupWidget and PivotTableSetupWidget actually edit the form values used on the page --- src/qqq/components/forms/EntityForm.tsx | 221 +++++++++++++++++------- 1 file changed, 160 insertions(+), 61 deletions(-) diff --git a/src/qqq/components/forms/EntityForm.tsx b/src/qqq/components/forms/EntityForm.tsx index 98fee7e..d4b3c8d 100644 --- a/src/qqq/components/forms/EntityForm.tsx +++ b/src/qqq/components/forms/EntityForm.tsx @@ -43,7 +43,9 @@ import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils"; import MDTypography from "qqq/components/legacy/MDTypography"; import HelpContent from "qqq/components/misc/HelpContent"; import QRecordSidebar from "qqq/components/misc/RecordSidebar"; +import PivotTableSetupWidget from "qqq/components/widgets/misc/PivotTableSetupWidget"; import RecordGridWidget, {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget"; +import ReportSetupWidget from "qqq/components/widgets/misc/ReportSetupWidget"; import HtmlUtils from "qqq/utils/HtmlUtils"; import Client from "qqq/utils/qqq/Client"; import TableUtils from "qqq/utils/qqq/TableUtils"; @@ -77,6 +79,15 @@ EntityForm.defaultProps = { onSubmitCallback: null, }; + +//////////////////////////////////////////////////////////////////////////// +// define a function that we can make referenes to, which we'll overwrite // +// with formik's setFieldValue function, once we're inside formik. // +//////////////////////////////////////////////////////////////////////////// +let formikSetFieldValueFunction = (field: string, value: any, shouldValidate?: boolean): void => +{ +} + function EntityForm(props: Props): JSX.Element { const qController = Client.getInstance(); @@ -108,6 +119,9 @@ function EntityForm(props: Props): JSX.Element const [notAllowedError, setNotAllowedError] = useState(null as string); + const [recordValuesJSON, setRecordValuesJSON] = useState(""); + const [formValues, setFormValues] = useState({} as {[name: string]: any}); + const {pageHeader, setPageHeader} = useContext(QContext); const navigate = useNavigate(); @@ -269,6 +283,21 @@ function EntityForm(props: Props): JSX.Element } + /******************************************************************************* + ** Watch the record values - if they change, re-render widgets + *******************************************************************************/ + useEffect(() => + { + const newRenderedWidgetSections: {[name: string]: JSX.Element} = {}; + for (let widgetName in renderedWidgetSections) + { + const widgetMetaData = metaData.widgets.get(widgetName); + newRenderedWidgetSections[widgetName] = getWidgetSection(widgetMetaData, childListWidgetData[widgetName]); + } + setRenderedWidgetSections(newRenderedWidgetSections); + }, [recordValuesJSON]); + + /******************************************************************************* ** render a section (full of fields) as a form *******************************************************************************/ @@ -319,25 +348,66 @@ function EntityForm(props: Props): JSX.Element } + + /******************************************************************************* + ** if we have a widget that wants to set form-field values, they can take this + ** function in as a callback, and then call it with their values. + *******************************************************************************/ + function setFormFieldValuesFromWidget(values: {[name: string]: any}) + { + for (let key in values) + { + formikSetFieldValueFunction(key, values[key]); + } + } + + /******************************************************************************* ** render a section as a widget *******************************************************************************/ function getWidgetSection(widgetMetaData: QWidgetMetaData, widgetData: any): JSX.Element { - widgetData.viewAllLink = null; - widgetMetaData.showExportButton = false; + if(widgetMetaData.type == "childRecordList") + { + widgetData.viewAllLink = null; + widgetMetaData.showExportButton = false; - return openAddChildRecord(widgetMetaData.name, widgetData)} - editRecordCallback={(rowIndex) => openEditChildRecord(widgetMetaData.name, widgetData, rowIndex)} - deleteRecordCallback={(rowIndex) => deleteChildRecord(widgetMetaData.name, widgetData, rowIndex)} - />; + return openAddChildRecord(widgetMetaData.name, widgetData)} + editRecordCallback={(rowIndex) => openEditChildRecord(widgetMetaData.name, widgetData, rowIndex)} + deleteRecordCallback={(rowIndex) => deleteChildRecord(widgetMetaData.name, widgetData, rowIndex)} + />; + } + + if(widgetMetaData.type == "reportSetup") + { + return + } + + if(widgetMetaData.type == "pivotTableSetup") + { + return + } + + return (Unsupported widget type: {widgetMetaData.type}) } @@ -373,7 +443,21 @@ function EntityForm(props: Props): JSX.Element ///////////////////////////////////////////////// const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData, [...tableMetaData.fields.keys()], (section: QTableSection) => { - return section.widgetName && metaData.widgets.get(section.widgetName)?.type == "childRecordList" && metaData.widgets.get(section.widgetName)?.defaultValues?.has("manageAssociationName"); + const widget = metaData.widgets.get(section.widgetName); + if(widget) + { + if(widget.type == "childRecordList" && widget.defaultValues?.has("manageAssociationName")) + { + return (true); + } + + if(widget.type == "reportSetup" || widget.type == "pivotTableSetup") + { + return (true); + } + } + + return (false); }); setTableSections(tableSections); @@ -549,13 +633,7 @@ function EntityForm(props: Props): JSX.Element } const hasFields = section.fieldNames && section.fieldNames.length > 0; - const hasChildRecordListWidget = section.widgetName && metaData.widgets.get(section.widgetName)?.type == "childRecordList"; - if (!hasFields && !hasChildRecordListWidget) - { - continue; - } - - if (hasFields) + if(hasFields) { for (let j = 0; j < section.fieldNames.length; j++) { @@ -599,6 +677,7 @@ function EntityForm(props: Props): JSX.Element newRenderedWidgetSections[section.widgetName] = getWidgetSection(widgetMetaData, widgetData); newChildListWidgetData[section.widgetName] = widgetData; } + ////////////////////////////////////// // capture the tier1 section's name // ////////////////////////////////////// @@ -924,51 +1003,71 @@ function EntityForm(props: Props): JSX.Element errors, touched, isSubmitting, - }) => ( -
- + setFieldValue, + }) => + { + if(values) + { + const newRecordValuesJSON = JSON.stringify(values); + if(recordValuesJSON != newRecordValuesJSON) + { + setRecordValuesJSON(newRecordValuesJSON); + setFormValues(values) + } + } - - - - - - - {tableMetaData?.iconName} - - - - - {formTitle} - - - {t1section && getSectionHelp(t1section)} - { - t1sectionName && formFields ? ( - - - {getFormSection(t1section, values, touched, formFields.get(t1sectionName), errors, true)} - + /////////////////////////////////////////////////////////////////// + // once we're in the formik form, use its setFieldValue function // + // over top of the default one we created globally // + /////////////////////////////////////////////////////////////////// + formikSetFieldValueFunction = setFieldValue; + + return ( + + + + + + + + + + {tableMetaData?.iconName} + + - ) : null - } - - - {formFields && nonT1Sections.length ? nonT1Sections.map((section: QTableSection) => ( - - {renderSection(section, values, touched, formFields, errors)} + + {formTitle} + + + {t1section && getSectionHelp(t1section)} + { + t1sectionName && formFields ? ( + + + {getFormSection(t1section, values, touched, formFields.get(t1sectionName), errors, true)} + + + ) : null + } + - )) : null} + {formFields && nonT1Sections.length ? nonT1Sections.map((section: QTableSection) => ( + + {renderSection(section, values, touched, formFields, errors)} + + )) : null} - - - - - - + + + + + + - - )} + + ); + }} { From 034264eaa1edb5b8f8ea0e70888ef9b2c75bbcb3 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 9 Apr 2024 15:59:23 -0500 Subject: [PATCH 04/22] CE-1115 - Add options to control appearance; make hiddenFields ignore the selected field; --- src/qqq/components/misc/FieldAutoComplete.tsx | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/src/qqq/components/misc/FieldAutoComplete.tsx b/src/qqq/components/misc/FieldAutoComplete.tsx index 4f789ae..c80be15 100644 --- a/src/qqq/components/misc/FieldAutoComplete.tsx +++ b/src/qqq/components/misc/FieldAutoComplete.tsx @@ -25,7 +25,7 @@ import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstan import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import Autocomplete, {AutocompleteRenderOptionState} from "@mui/material/Autocomplete"; import TextField from "@mui/material/TextField"; -import React, {ReactNode} from "react"; +import React, {ReactNode, useState} from "react"; interface FieldAutoCompleteProps { @@ -37,6 +37,9 @@ interface FieldAutoCompleteProps autoFocus?: boolean; forceOpen?: boolean; hiddenFieldNames?: string[]; + variant?: "standard" | "filled" | "outlined" + label?: string + textFieldSX?: any } FieldAutoComplete.defaultProps = @@ -44,17 +47,20 @@ FieldAutoComplete.defaultProps = defaultValue: null, autoFocus: false, forceOpen: null, - hiddenFieldNames: [] + hiddenFieldNames: [], + variant: "standard", + label: "Field", + textFieldSX: null, }; -function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[]) +function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[], selectedFieldName: string) { const sortedFields = [...tableMetaData.fields.values()].sort((a, b) => a.label.localeCompare(b.label)); for (let i = 0; i < sortedFields.length; i++) { const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name; - if(hiddenFieldNames && hiddenFieldNames.indexOf(fieldName) > -1) + if(hiddenFieldNames && hiddenFieldNames.indexOf(fieldName) > -1 && fieldName != selectedFieldName) { continue; } @@ -63,10 +69,16 @@ function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: a } } -export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames}: FieldAutoCompleteProps): JSX.Element + +/******************************************************************************* + ** Component for rendering a list of field names from a table as an auto-complete. + *******************************************************************************/ +export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames, variant, label, textFieldSX}: FieldAutoCompleteProps): JSX.Element { + const [selectedFieldName, setSelectedFieldName] = useState(defaultValue ? defaultValue.fieldName : null) + const fieldOptions: any[] = []; - makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames); + makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames, selectedFieldName); let fieldsGroupBy = null; if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length > 0) @@ -77,7 +89,7 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi if (metaData.tables.has(exposedJoin.joinTable.name)) { fieldsGroupBy = (option: any) => `${option.table.label} fields`; - makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true, hiddenFieldNames); + makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true, hiddenFieldNames, selectedFieldName); } } } @@ -136,14 +148,24 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi alsoOpen["open"] = forceOpen; } + + /******************************************************************************* + ** + *******************************************************************************/ + function onChange(event: any, newValue: any, reason: string) + { + setSelectedFieldName(newValue ? newValue.fieldName : null) + handleFieldChange(event, newValue, reason); + } + return ( ()} + renderInput={(params) => ()} // @ts-ignore defaultValue={defaultValue} options={fieldOptions} - onChange={handleFieldChange} + onChange={onChange} isOptionEqualToValue={(option, value) => isFieldOptionEqual(option, value)} groupBy={fieldsGroupBy} getOptionLabel={(option) => getFieldOptionLabel(option)} From fb2e392dcb6f2468c5874e0e323854a627a08280 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 9 Apr 2024 16:00:09 -0500 Subject: [PATCH 05/22] CE-1115 - Initial working version --- .../widgets/misc/PivotTableSetupWidget.tsx | 573 ++++++++++++++++++ 1 file changed, 573 insertions(+) create mode 100644 src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx 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 && + + + + + + } +
+ +
+
+ } +
); +} From 37b854baf09bd98b8e1ebfe6f16d8d749ffd776d Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 9 Apr 2024 16:00:24 -0500 Subject: [PATCH 06/22] CE-1115 - export interface Column --- src/qqq/models/query/QQueryColumns.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/qqq/models/query/QQueryColumns.ts b/src/qqq/models/query/QQueryColumns.ts index 2a7f2c6..210805c 100644 --- a/src/qqq/models/query/QQueryColumns.ts +++ b/src/qqq/models/query/QQueryColumns.ts @@ -23,14 +23,13 @@ import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {GridPinnedColumns} from "@mui/x-data-grid-pro"; -import quickSightChart from "qqq/components/widgets/misc/QuickSightChart"; import DataGridUtils from "qqq/utils/DataGridUtils"; import TableUtils from "qqq/utils/qqq/TableUtils"; /******************************************************************************* ** member object *******************************************************************************/ -interface Column +export interface Column { name: string; isVisible: boolean; From f47924787a257ec5c8a45374c6506abecfbf923e Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 9 Apr 2024 16:03:15 -0500 Subject: [PATCH 07/22] CE-1115 - Update to be used as a modal, and to take a usage prop, e.g., to differentiate between being used as query screen vs. used on report-setup screen --- src/qqq/pages/records/query/RecordQuery.tsx | 159 +++++++++++++------- 1 file changed, 103 insertions(+), 56 deletions(-) diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 80bf1e1..d39b538 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -76,7 +76,7 @@ import ProcessUtils from "qqq/utils/qqq/ProcessUtils"; import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils"; import TableUtils from "qqq/utils/qqq/TableUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; -import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react"; +import React, {forwardRef, useContext, useEffect, useImperativeHandle, useReducer, useRef, useState} from "react"; import {useLocation, useNavigate, useSearchParams} from "react-router-dom"; const CURRENT_SAVED_VIEW_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedViewId"; @@ -84,18 +84,16 @@ const DENSITY_LOCAL_STORAGE_KEY_ROOT = "qqq.density"; const VIEW_LOCAL_STORAGE_KEY_ROOT = "qqq.recordQueryView"; export const TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT = "qqq.tableVariant"; +export type QueryScreenUsage = "queryScreen" | "reportSetup" interface Props { table?: QTableMetaData; launchProcess?: QProcessMetaData; + usage?: QueryScreenUsage; + isModal?: boolean; } -RecordQuery.defaultProps = { - table: null, - launchProcess: null -}; - /////////////////////////////////////////////////////// // define possible values for our pageState variable // /////////////////////////////////////////////////////// @@ -120,7 +118,7 @@ const getLoadingScreen = () => ** ** Yuge component. The best. Lots of very smart people are saying so. *******************************************************************************/ -function RecordQuery({table, launchProcess}: Props): JSX.Element +const RecordQuery = forwardRef(({table, usage, isModal}: Props, ref) => { const tableName = table.name; const [searchParams] = useSearchParams(); @@ -136,6 +134,16 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [firstRender, setFirstRender] = useState(true); const [isFirstRenderAfterChangingTables, setIsFirstRenderAfterChangingTables] = useState(false); + useImperativeHandle(ref, () => + { + return { + getCurrentView(): RecordQueryView + { + return view; + } + } + }); + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // manage "state" being passed from some screens (like delete) into query screen - by grabbing, and then deleting // //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -688,8 +696,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { if (localStorage.getItem(currentSavedViewLocalStorageKey)) { - currentSavedViewId = Number.parseInt(localStorage.getItem(currentSavedViewLocalStorageKey)); - navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`); + if(usage == "queryScreen") + { + currentSavedViewId = Number.parseInt(localStorage.getItem(currentSavedViewLocalStorageKey)); + navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`); + } } else { @@ -943,7 +954,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element console.log(`Received error for query ${thisQueryId}`); console.log(error); - var errorMessage; + let errorMessage; if (error && error.message) { errorMessage = error.message; @@ -2183,27 +2194,30 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element -
- - - { - setSelectionSubsetSizePromptOpen(false); - - if (value !== undefined) + { + usage == "queryScreen" && +
+ + { - if (typeof value === "number" && value > 0) + setSelectionSubsetSizePromptOpen(false); + + if (value !== undefined) { - programmaticallySelectSomeOrAllRows(value); - setSelectionSubsetSize(value); - setSelectFullFilterState("filterSubset"); + if (typeof value === "number" && value > 0) + { + programmaticallySelectSomeOrAllRows(value); + setSelectionSubsetSize(value); + setSelectFullFilterState("filterSubset"); + } + else + { + setAlertContent("Unexpected value: " + value); + } } - else - { - setAlertContent("Unexpected value: " + value); - } - } - }} /> -
+ }} /> +
+ }
{ @@ -2302,6 +2316,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element if (pageState == "ready") { const newFilterHash = JSON.stringify(prepQueryFilterForBackend(queryFilter)); + const filterForBackend = prepQueryFilterForBackend(queryFilter); + + const newFilterHash = JSON.stringify(filterForBackend); if (filterHash != newFilterHash) { setFilterHash(newFilterHash); @@ -2474,7 +2491,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { const currentSavedViewId = Number.parseInt(localStorage.getItem(currentSavedViewLocalStorageKey)); console.log(`returning to previously active saved view ${currentSavedViewId}`); - navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`); + if(usage == "queryScreen") + { + navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`); + } setViewIdInLocation(currentSavedViewId); ///////////////////////////////////////////////////////////////////////////////////////////////////// @@ -2623,7 +2643,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element let savedViewsComponent = null; if (metaData && metaData.processes.has("querySavedView")) { - savedViewsComponent = (); + savedViewsComponent = (); } @@ -2700,7 +2720,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element }; ////////////////////////////////////////////////////////////////////////////////////////////////////////// - // these numbers help set the height of the grid (so page won't scroll) based on spcae above & below it // + // these numbers help set the height of the grid (so page won't scroll) based on space above & below it // ////////////////////////////////////////////////////////////////////////////////////////////////////////// let spaceBelowGrid = 40; let spaceAboveGrid = 205; @@ -2714,40 +2734,48 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element spaceAboveGrid += 60; } + if(isModal) + { + spaceAboveGrid += 130; + } + //////////////////////// // main screen render // //////////////////////// - return ( - + const body = ( + {pageLoadingState.isLoading() && ""} {pageLoadingState.isLoadingSlow() && "Loading..."} - {pageLoadingState.isNotLoading() && getPageHeader(tableMetaData, visibleJoinTables, tableVariant)} + {pageLoadingState.isNotLoading() && !isModal && getPageHeader(tableMetaData, visibleJoinTables, tableVariant)} - - - + { + !isModal && + + + + { + tableMetaData && + + } + { - tableMetaData && - + table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission && + } - { - table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission && - - } - + }
{/* @@ -2801,6 +2829,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setQuickFilterFieldNames={doSetQuickFilterFieldNames} gridApiRef={gridApiRef} mode={mode} + queryScreenUsage={usage} setMode={doSetMode} savedViewsComponent={savedViewsComponent} columnMenuComponent={buildColumnMenu()} @@ -2841,7 +2870,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element sortingMode="server" filterMode="server" page={pageNumber} - checkboxSelection + checkboxSelection={usage == "queryScreen"} disableSelectionOnClick autoHeight={false} rows={rows} @@ -2850,7 +2879,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element rowBuffer={10} rowCount={totalRecords === null || totalRecords === undefined ? 0 : totalRecords} onPageSizeChange={handleRowsPerPageChange} - onRowClick={handleRowClick} + onRowClick={usage == "queryScreen" ? handleRowClick : null} onStateChange={handleStateChange} density={density} loading={loading} @@ -2908,8 +2937,26 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element }
-
+ ); -} + + if(isModal) + { + return body; + } + + return ( + {body} + ) +}) + + +RecordQuery.defaultProps = { + table: null, + usage: "queryScreen", + launchProcess: null, + isModal: false, +}; + export default RecordQuery; From dee4b91a96bfaa0950e6ef77d17816e2ea883f06 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 9 Apr 2024 16:03:37 -0500 Subject: [PATCH 08/22] CE-1115 - Pass record into DashboardWidgets --- src/qqq/pages/records/view/RecordView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qqq/pages/records/view/RecordView.tsx b/src/qqq/pages/records/view/RecordView.tsx index 4743b19..d586848 100644 --- a/src/qqq/pages/records/view/RecordView.tsx +++ b/src/qqq/pages/records/view/RecordView.tsx @@ -505,7 +505,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element sectionFieldElements.set(section.name, - + ); From 703868a72520d9490d57103f20a558a3cd4fb2f7 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 9 Apr 2024 16:04:35 -0500 Subject: [PATCH 09/22] CE-1115 - Initial working version --- .../widgets/misc/ReportSetupWidget.tsx | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 src/qqq/components/widgets/misc/ReportSetupWidget.tsx diff --git a/src/qqq/components/widgets/misc/ReportSetupWidget.tsx b/src/qqq/components/widgets/misc/ReportSetupWidget.tsx new file mode 100644 index 0000000..510c4a7 --- /dev/null +++ b/src/qqq/components/widgets/misc/ReportSetupWidget.tsx @@ -0,0 +1,289 @@ +/* + * 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 {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData"; +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import {Alert, Collapse} from "@mui/material"; +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 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 QQueryColumns, {Column} from "qqq/models/query/QQueryColumns"; +import RecordQuery from "qqq/pages/records/query/RecordQuery"; +import Client from "qqq/utils/qqq/Client"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; +import React, {useContext, useEffect, useRef, useState} from "react"; + +interface ReportSetupWidgetProps +{ + isEditable: boolean; + widgetMetaData: QWidgetMetaData; + recordValues: {[name: string]: any}; + onSaveCallback?: (values: {[name: string]: any}) => void; +} + +ReportSetupWidget.defaultProps = { + onSaveCallback: null +}; + +const qController = Client.getInstance(); + +/******************************************************************************* + ** Component for editing the main setup of a report - that is: filter & columns + *******************************************************************************/ +export default function ReportSetupWidget({isEditable, widgetMetaData, recordValues, onSaveCallback}: ReportSetupWidgetProps): JSX.Element +{ + const [modalOpen, setModalOpen] = useState(false); + const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData); + + const [alertContent, setAlertContent] = useState(null as string); + + const recordQueryRef = useRef(); + + + let queryFilter = recordValues["queryFilterJson"] && JSON.parse(recordValues["queryFilterJson"]) as QQueryFilter; + if(!queryFilter) + { + queryFilter = new QQueryFilter(); + } + + let columns = recordValues["columnsJson"] && JSON.parse(recordValues["columnsJson"]) as QQueryColumns; + if(!columns) + { + columns = new QQueryColumns(); + } + + useEffect(() => + { + if (recordValues["tableName"] && (tableMetaData == null || tableMetaData.name != recordValues["tableName"])) + { + (async () => + { + const tableMetaData = await qController.loadTableMetaData(recordValues["tableName"]) + setTableMetaData(tableMetaData); + })(); + } + }, [recordValues]); + + + /******************************************************************************* + ** + *******************************************************************************/ + function openEditor() + { + if(recordValues["tableName"]) + { + setModalOpen(true); + } + else + { + setAlertContent("You must select a table before you can edit filters and columns") + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function saveClicked() + { + if(!onSaveCallback) + { + console.log("onSaveCallback was not defined"); + return; + } + + // @ts-ignore possibly 'undefined'. + const view = recordQueryRef?.current?.getCurrentView(); + onSaveCallback({queryFilterJson: JSON.stringify(view.queryFilter), columnsJson: JSON.stringify(view.queryColumns)}); + + closeEditor(); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function closeEditor(event?: {}, reason?: "backdropClick" | "escapeKeyDown") + { + if(reason == "backdropClick" || reason == "escapeKeyDown") + { + return; + } + + setModalOpen(false); + } + + const labelAdditionalComponentsRight: LabelComponent[] = [] + if(isEditable) + { + labelAdditionalComponentsRight.push(new HeaderLinkButton("Edit Filters and Columns", openEditor)) + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function renderColumn(column: Column): JSX.Element + { + const [field, table] = FilterUtils.getField(tableMetaData, column.name) + + if(!column || !column.isVisible || column.name == "__check__" || !field) + { + return (); + } + + const tableLabelPart = table.name != tableMetaData.name ? table.label + ": " : ""; + + return ( + {tableLabelPart}{field.label} + ); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function mayShowQueryPreview(): boolean + { + if(tableMetaData) + { + if(queryFilter?.criteria?.length > 0 || queryFilter?.subFilters?.length > 0) + { + return (true); + } + } + + return (false); + } + + /******************************************************************************* + ** + *******************************************************************************/ + function mayShowColumnsPreview(): boolean + { + if(tableMetaData) + { + if(columns?.columns?.length > 0) + { + return (true); + } + } + + 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." + + return ( + + + setAlertContent(null)}>{alertContent} + + +
Query Filter
+ { + mayShowQueryPreview() && + 0} removeCriteriaByIndexCallback={null} /> + } + { + !mayShowQueryPreview() && + + { + isEditable && + Add Filters + } + { + !isEditable && Your report has no filters. + } + + } +
+ +
Columns
+ + { + mayShowColumnsPreview() && + columns.columns.map((column, i) => {renderColumn(column)}) + } + { + !mayShowColumnsPreview() && + + { + isEditable && + Add Columns + } + { + !isEditable && Your report has no filters. + } + + } + +
+ { + modalOpen && + closeEditor(event, reason)}> +
+ + +

Edit Filters and Columns

+ { + showHelp && + + {formattedHelpContent} + + } + { + tableMetaData && + } + + + + + + + +
+
+
+
+ } +
+
); +} From 87ffd821f89535c25ce317b22904604b4393b6bb Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 9 Apr 2024 16:04:50 -0500 Subject: [PATCH 10/22] CE-1115 - Update to be used as a modal, and to take a usage prop, e.g., to differentiate between being used as query screen vs. used on report-setup screen --- src/qqq/components/misc/SavedViews.tsx | 58 +++++++++++++++++--------- 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/src/qqq/components/misc/SavedViews.tsx b/src/qqq/components/misc/SavedViews.tsx index 2000137..000cca6 100644 --- a/src/qqq/components/misc/SavedViews.tsx +++ b/src/qqq/components/misc/SavedViews.tsx @@ -44,6 +44,7 @@ import QContext from "QContext"; import colors from "qqq/assets/theme/base/colors"; import {QCancelButton, QDeleteButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; import RecordQueryView from "qqq/models/query/RecordQueryView"; +import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils"; import React, {useContext, useEffect, useRef, useState} from "react"; @@ -60,9 +61,10 @@ interface Props viewAsJson?: string; viewOnChangeCallback?: (selectedSavedViewId: number) => void; loadingSavedView: boolean + queryScreenUsage: QueryScreenUsage; } -function SavedViews({qController, metaData, tableMetaData, currentSavedView, tableDefaultView, view, viewAsJson, viewOnChangeCallback, loadingSavedView}: Props): JSX.Element +function SavedViews({qController, metaData, tableMetaData, currentSavedView, tableDefaultView, view, viewAsJson, viewOnChangeCallback, loadingSavedView, queryScreenUsage}: Props): JSX.Element { const navigate = useNavigate(); @@ -91,6 +93,14 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab const {accentColor, accentColorLight} = useContext(QContext); + ///////////////////////////////////////////////////////////////////////////////////////// + // this component is used by - but that component has different usages - // + // e.g., the full-fledged query screen, but also, within other screens (e.g., a modal // + // under the ReportSetupWidget). So, there are some behaviors we only want when we're // + // on the full-fledged query screen, such as changing the URL with saved view ids. // + ///////////////////////////////////////////////////////////////////////////////////////// + const isQueryScreen = queryScreenUsage == "queryScreen"; + const openSavedViewsMenu = (event: any) => setSavedViewsMenu(event.currentTarget); const closeSavedViewsMenu = () => setSavedViewsMenu(null); @@ -142,7 +152,10 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab setSaveFilterPopupOpen(false); closeSavedViewsMenu(); viewOnChangeCallback(record.values.get("id")); - navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedView/${record.values.get("id")}`); + if(isQueryScreen) + { + navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedView/${record.values.get("id")}`); + } }; @@ -175,7 +188,10 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab case CLEAR_OPTION: setSaveFilterPopupOpen(false) viewOnChangeCallback(null); - navigate(metaData.getTablePathByName(tableMetaData.name)); + if(isQueryScreen) + { + navigate(metaData.getTablePathByName(tableMetaData.name)); + } break; case RENAME_OPTION: if(currentSavedView != null) @@ -419,7 +435,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab > View Actions { - hasStorePermission && + isQueryScreen && hasStorePermission && Save your current filters, columns and settings, for quick re-use at a later time.

You will be prompted to enter a name if you choose this option.}> handleDropdownOptionClick(SAVE_OPTION)}> save @@ -428,7 +444,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
} { - hasStorePermission && currentSavedView != null && + isQueryScreen && hasStorePermission && currentSavedView != null && handleDropdownOptionClick(RENAME_OPTION)}> edit @@ -437,7 +453,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab } { - hasStorePermission && currentSavedView != null && + isQueryScreen && hasStorePermission && currentSavedView != null && handleDropdownOptionClick(DUPLICATE_OPTION)}> content_copy @@ -446,7 +462,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab } { - hasDeletePermission && currentSavedView != null && + isQueryScreen && hasDeletePermission && currentSavedView != null && handleDropdownOptionClick(DELETE_OPTION)}> delete @@ -580,19 +596,23 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab { !currentSavedView && viewIsModified && <> - - Unsaved Changes -
    - { - viewDiffs.map((s: string, i: number) =>
  • {s}
  • ) - } -
- }> - -
+ { + isQueryScreen && <> + + Unsaved Changes +
    + { + viewDiffs.map((s: string, i: number) =>
  • {s}
  • ) + } +
+ }> + +
- {/* vertical rule */} - + {/* vertical rule */} + + + } From d5381e23bfe3b1a95ff185191404a2d6791ff301 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 9 Apr 2024 16:06:25 -0500 Subject: [PATCH 11/22] CE-1115 - add HeaderLinkButton and HeaderToggleComponent, and start doing right-additional things as components (despite backward naming!) --- src/qqq/components/widgets/Widget.tsx | 71 ++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/qqq/components/widgets/Widget.tsx b/src/qqq/components/widgets/Widget.tsx index b421228..0532363 100644 --- a/src/qqq/components/widgets/Widget.tsx +++ b/src/qqq/components/widgets/Widget.tsx @@ -21,21 +21,23 @@ import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData"; +import {InputLabel} from "@mui/material"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; import Icon from "@mui/material/Icon"; +import Switch from "@mui/material/Switch"; import Tooltip from "@mui/material/Tooltip/Tooltip"; import Typography from "@mui/material/Typography"; import parse from "html-react-parser"; -import React, {useContext, useEffect, useState} from "react"; -import {NavigateFunction, useNavigate} from "react-router-dom"; import QContext from "QContext"; import colors from "qqq/assets/theme/base/colors"; import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent"; import WidgetDropdownMenu, {DropdownOption} from "qqq/components/widgets/components/WidgetDropdownMenu"; import {WidgetUtils} from "qqq/components/widgets/WidgetUtils"; import HtmlUtils from "qqq/utils/HtmlUtils"; +import React, {useContext, useEffect, useState} from "react"; +import {NavigateFunction, useNavigate} from "react-router-dom"; export interface WidgetData { @@ -60,6 +62,7 @@ interface Props labelAdditionalComponentsLeft: LabelComponent[]; labelAdditionalElementsLeft: JSX.Element[]; labelAdditionalComponentsRight: LabelComponent[]; + labelAdditionalElementsRight: JSX.Element[]; labelBoxAdditionalSx?: any; widgetMetaData?: QWidgetMetaData; widgetData?: WidgetData; @@ -80,6 +83,7 @@ Widget.defaultProps = { labelAdditionalComponentsLeft: [], labelAdditionalElementsLeft: [], labelAdditionalComponentsRight: [], + labelAdditionalElementsRight: [], labelBoxAdditionalSx: {}, omitPadding: false, }; @@ -160,6 +164,65 @@ export class HeaderIcon extends LabelComponent } +/******************************************************************************* + ** + *******************************************************************************/ +export class HeaderLinkButton extends LabelComponent +{ + label: string; + onClickCallback: () => void; + + + constructor(label: string, onClickCallback: () => void) + { + super(); + this.label = label; + this.onClickCallback = onClickCallback; + } + + render = (args: LabelComponentRenderArgs): JSX.Element => + { + return ( + + ); + }; +} + + +/******************************************************************************* + ** + *******************************************************************************/ +interface HeaderToggleComponentProps +{ + label: string; + getValue: () => boolean; + onClickCallback: () => void; +} + +export function HeaderToggleComponent({label, getValue, onClickCallback}: HeaderToggleComponentProps): JSX.Element +{ + console.log(`@dk in HTComponent, getValue(): ${getValue()}`); + + const onClick = () => + { + onClickCallback(); + } + + return ( + + + {label} + + + ); +} + + + /******************************************************************************* ** *******************************************************************************/ @@ -564,6 +627,8 @@ function Widget(props: React.PropsWithChildren): JSX.Element localLabelAdditionalElementsLeft.push(WidgetUtils.generateExportButton(onExportClick)); } + let localLabelAdditionalElementsRight = [...props.labelAdditionalElementsRight]; + const hasPermission = props.widgetData?.hasPermission === undefined || props.widgetData?.hasPermission === true; const isSet = (v: any): boolean => @@ -580,6 +645,7 @@ function Widget(props: React.PropsWithChildren): JSX.Element needLabelBox ||= (labelComponentsLeft && labelComponentsLeft.length > 0); needLabelBox ||= (localLabelAdditionalElementsLeft && localLabelAdditionalElementsLeft.length > 0); needLabelBox ||= (labelComponentsRight && labelComponentsRight.length > 0); + needLabelBox ||= (localLabelAdditionalElementsRight && localLabelAdditionalElementsRight.length > 0); needLabelBox ||= isSet(props.widgetData?.icon); needLabelBox ||= isSet(props.widgetData?.label); needLabelBox ||= isSet(props.widgetMetaData?.label); @@ -711,6 +777,7 @@ function Widget(props: React.PropsWithChildren): JSX.Element }) ) } + {localLabelAdditionalElementsRight} } From 6b8049d4ce8856fdf7e879fe964c9041249678f6 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 9 Apr 2024 16:11:46 -0500 Subject: [PATCH 12/22] Remove dupe line from half-commit of a WIP change --- src/qqq/pages/records/query/RecordQuery.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index d39b538..bfc2315 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -2315,7 +2315,6 @@ const RecordQuery = forwardRef(({table, usage, isModal}: Props, ref) => if (pageState == "ready") { - const newFilterHash = JSON.stringify(prepQueryFilterForBackend(queryFilter)); const filterForBackend = prepQueryFilterForBackend(queryFilter); const newFilterHash = JSON.stringify(filterForBackend); From 803725b8f19b3db1a237909c736f8fc00bd2f62b Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 11 Apr 2024 10:07:41 -0500 Subject: [PATCH 13/22] CE-1115 - initial prototype of field-rules - e.g., clear one field when another changes --- src/qqq/components/forms/EntityForm.tsx | 89 +++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 7 deletions(-) diff --git a/src/qqq/components/forms/EntityForm.tsx b/src/qqq/components/forms/EntityForm.tsx index d4b3c8d..6a1cd45 100644 --- a/src/qqq/components/forms/EntityForm.tsx +++ b/src/qqq/components/forms/EntityForm.tsx @@ -119,7 +119,7 @@ function EntityForm(props: Props): JSX.Element const [notAllowedError, setNotAllowedError] = useState(null as string); - const [recordValuesJSON, setRecordValuesJSON] = useState(""); + const [formValuesJSON, setFormValuesJSON] = useState(""); const [formValues, setFormValues] = useState({} as {[name: string]: any}); const {pageHeader, setPageHeader} = useContext(QContext); @@ -295,7 +295,7 @@ function EntityForm(props: Props): JSX.Element newRenderedWidgetSections[widgetName] = getWidgetSection(widgetMetaData, childListWidgetData[widgetName]); } setRenderedWidgetSections(newRenderedWidgetSections); - }, [recordValuesJSON]); + }, [formValuesJSON]); /******************************************************************************* @@ -928,6 +928,36 @@ function EntityForm(props: Props): JSX.Element })(); }; + + // todo - get from meta data! + const fieldRules = + [ + {trigger: "onChange", sourceField: "tableName", action: "clearOtherField", targetField: "columnsJson"}, + {trigger: "onChange", sourceField: "tableName", action: "clearOtherField", targetField: "queryFilterJson"}, + {trigger: "onChange", sourceField: "tableName", action: "clearOtherField", targetField: "pivotTableJson"} + ] + + + /******************************************************************************* + ** process a form-field having a changed value (e.g., apply field rules). + *******************************************************************************/ + function handleChangedFieldValue(fieldName: string, oldValue: any, newValue: any, valueChangesToMake: {[fieldName: string]: any}) + { + for (let fieldRule of fieldRules) + { + if(fieldRule.trigger == "onChange" && fieldRule.sourceField == fieldName) + { + switch (fieldRule.action) + { + case "clearOtherField": + console.log(`Clearing value from [${fieldRule.targetField}] due to change in [${fieldName}]`); + valueChangesToMake[fieldRule.targetField] = null; + break; + } + } + } + } + const formId = props.id != null ? `edit-${tableMetaData?.name}-form` : `create-${tableMetaData?.name}-form`; let body; @@ -966,7 +996,7 @@ function EntityForm(props: Props): JSX.Element else { body = ( - + { (alertContent || warningContent) && @@ -1004,15 +1034,60 @@ function EntityForm(props: Props): JSX.Element touched, isSubmitting, setFieldValue, + dirty }) => { + ///////////////////////////////////////////////// + // if we have values from formik, look at them // + ///////////////////////////////////////////////// if(values) { - const newRecordValuesJSON = JSON.stringify(values); - if(recordValuesJSON != newRecordValuesJSON) + //////////////////////////////////////////////////////////////////////// + // use stringified values as cheap/easy way to see if any are changed // + //////////////////////////////////////////////////////////////////////// + const newFormValuesJSON = JSON.stringify(values); + if(formValuesJSON != newFormValuesJSON) { - setRecordValuesJSON(newRecordValuesJSON); - setFormValues(values) + const valueChangesToMake: {[fieldName: string]: any} = {}; + + //////////////////////////////////////////////////////////////////// + // if the form is dirty (e.g., we're not doing the initial load), // + // then process rules for any changed fields // + //////////////////////////////////////////////////////////////////// + if(dirty) + { + for (let fieldName in values) + { + if (formValues[fieldName] != values[fieldName]) + { + handleChangedFieldValue(fieldName, formValues[fieldName], values[fieldName], valueChangesToMake); + } + formValues[fieldName] = values[fieldName]; + } + } + else + { + ///////////////////////////////////////////////////////////////////////////////////// + // if the form is clean, make sure the formValues object has all form values in it // + ///////////////////////////////////////////////////////////////////////////////////// + for (let fieldName in values) + { + formValues[fieldName] = values[fieldName]; + } + } + + ///////////////////////////////////////////////////////////////////////////// + // if there were any changes to be made from the rule evaluation, // + // make those changes in the formValues map, and in formik (setFieldValue) // + ///////////////////////////////////////////////////////////////////////////// + for (let fieldName in valueChangesToMake) + { + formValues[fieldName] = valueChangesToMake[fieldName]; + setFieldValue(fieldName, valueChangesToMake[fieldName], false); + } + + setFormValues(formValues) + setFormValuesJSON(JSON.stringify(values)); } } From 7e2a46b362ee93370b25b623cbcfdda7326175ad Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 11 Apr 2024 10:07:59 -0500 Subject: [PATCH 14/22] CE-1115 - take optional array of availableFieldNames (e.g., to only show a sub-set) --- src/qqq/components/misc/FieldAutoComplete.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/qqq/components/misc/FieldAutoComplete.tsx b/src/qqq/components/misc/FieldAutoComplete.tsx index c80be15..495f831 100644 --- a/src/qqq/components/misc/FieldAutoComplete.tsx +++ b/src/qqq/components/misc/FieldAutoComplete.tsx @@ -37,6 +37,7 @@ interface FieldAutoCompleteProps autoFocus?: boolean; forceOpen?: boolean; hiddenFieldNames?: string[]; + availableFieldNames?: string[]; variant?: "standard" | "filled" | "outlined" label?: string textFieldSX?: any @@ -48,12 +49,13 @@ FieldAutoComplete.defaultProps = autoFocus: false, forceOpen: null, hiddenFieldNames: [], + availableFieldNames: [], variant: "standard", label: "Field", textFieldSX: null, }; -function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[], selectedFieldName: string) +function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[], availableFieldNames: string[], selectedFieldName: string) { const sortedFields = [...tableMetaData.fields.values()].sort((a, b) => a.label.localeCompare(b.label)); for (let i = 0; i < sortedFields.length; i++) @@ -65,6 +67,11 @@ function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: a continue; } + if(availableFieldNames && availableFieldNames.indexOf(fieldName) == -1) + { + continue; + } + fieldOptions.push({field: sortedFields[i], table: tableMetaData, fieldName: fieldName}); } } @@ -73,12 +80,12 @@ function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: a /******************************************************************************* ** Component for rendering a list of field names from a table as an auto-complete. *******************************************************************************/ -export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames, variant, label, textFieldSX}: FieldAutoCompleteProps): JSX.Element +export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames, availableFieldNames, variant, label, textFieldSX}: FieldAutoCompleteProps): JSX.Element { const [selectedFieldName, setSelectedFieldName] = useState(defaultValue ? defaultValue.fieldName : null) const fieldOptions: any[] = []; - makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames, selectedFieldName); + makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames, availableFieldNames, selectedFieldName); let fieldsGroupBy = null; if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length > 0) @@ -89,7 +96,7 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi if (metaData.tables.has(exposedJoin.joinTable.name)) { fieldsGroupBy = (option: any) => `${option.table.label} fields`; - makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true, hiddenFieldNames, selectedFieldName); + makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true, hiddenFieldNames, availableFieldNames, selectedFieldName); } } } From cdec98afd89c707291bcae767bed5b46e670932e Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 11 Apr 2024 10:09:54 -0500 Subject: [PATCH 15/22] CE-1115 - Updated qfc (widget help content multi-role); add react dnd --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 71f4964..e9eaf8a 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", - "@kingsrook/qqq-frontend-core": "1.0.90", + "@kingsrook/qqq-frontend-core": "1.0.91", "@mui/icons-material": "5.4.1", "@mui/material": "5.11.1", "@mui/styles": "5.11.1", @@ -39,6 +39,8 @@ "react-ace": "10.1.0", "react-chartjs-2": "3.0.4", "react-cookie": "4.1.1", + "react-dnd": "16.0.1", + "react-dnd-html5-backend": "16.0.1", "react-dom": "18.0.0", "react-github-btn": "1.2.1", "react-google-drive-picker": "^1.2.0", From cb7fa641ebe86b297cd354c947239576a21eb598 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 11 Apr 2024 10:11:43 -0500 Subject: [PATCH 16/22] CE-1115 checkpoint on report & pivotTable setup widgets: - refactor into sub-components - working drag & drop - more help content - disable things rather than alert if no table --- src/qqq/components/widgets/Widget.tsx | 49 +- .../widgets/misc/PivotTableGroupByElement.tsx | 204 +++++++ .../widgets/misc/PivotTableSetupWidget.tsx | 530 ++++++++++-------- .../widgets/misc/PivotTableValueElement.tsx | 279 +++++++++ .../widgets/misc/ReportSetupWidget.tsx | 82 ++- .../models/misc/PivotTableDefinitionModels.ts | 115 ++++ src/qqq/pages/records/view/RecordView.tsx | 2 +- src/qqq/styles/qqq-override-styles.css | 6 + 8 files changed, 988 insertions(+), 279 deletions(-) create mode 100644 src/qqq/components/widgets/misc/PivotTableGroupByElement.tsx create mode 100644 src/qqq/components/widgets/misc/PivotTableValueElement.tsx create mode 100644 src/qqq/models/misc/PivotTableDefinitionModels.ts 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 From 5e0e4c37bbfabbaa8a5d5b2562faa9c39c7cbe4c Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 11 Apr 2024 10:25:12 -0500 Subject: [PATCH 17/22] CE-1115 Update qfc (rebuilt to include latest dependabot updates) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e9eaf8a..7fa702e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", - "@kingsrook/qqq-frontend-core": "1.0.91", + "@kingsrook/qqq-frontend-core": "1.0.92", "@mui/icons-material": "5.4.1", "@mui/material": "5.11.1", "@mui/styles": "5.11.1", From 53c3e4d078cd01937f0ad567849bd8282793b8fc Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 11 Apr 2024 10:49:14 -0500 Subject: [PATCH 18/22] CE-1115 change exception status to numbers per qfc, axios update --- src/App.tsx | 2 +- src/qqq/components/widgets/misc/DataBagViewer.tsx | 2 +- src/qqq/components/widgets/misc/ScriptViewer.tsx | 2 +- src/qqq/pages/processes/ProcessRun.tsx | 2 +- src/qqq/pages/records/view/RecordDeveloperView.tsx | 2 +- src/qqq/utils/qqq/Client.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 269bb62..35e3c2d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -575,7 +575,7 @@ export default function App() console.error(e); if (e instanceof QException) { - if ((e as QException).status === "401") + if ((e as QException).status === 401) { console.log("Exception is a QException with status = 401. Clearing some of localStorage & cookies"); qController.clearAuthenticationMetaDataLocalStorage(); diff --git a/src/qqq/components/widgets/misc/DataBagViewer.tsx b/src/qqq/components/widgets/misc/DataBagViewer.tsx index b1d1e48..edb384a 100644 --- a/src/qqq/components/widgets/misc/DataBagViewer.tsx +++ b/src/qqq/components/widgets/misc/DataBagViewer.tsx @@ -119,7 +119,7 @@ export default function DataBagViewer({dataBagId}: Props): JSX.Element { if (e instanceof QException) { - if ((e as QException).status === "404") + if ((e as QException).status === 404) { setNotFoundMessage("Data bag data could not be found."); return; diff --git a/src/qqq/components/widgets/misc/ScriptViewer.tsx b/src/qqq/components/widgets/misc/ScriptViewer.tsx index 0c51e6e..e754d7c 100644 --- a/src/qqq/components/widgets/misc/ScriptViewer.tsx +++ b/src/qqq/components/widgets/misc/ScriptViewer.tsx @@ -169,7 +169,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc { if (e instanceof QException) { - if ((e as QException).status === "404") + if ((e as QException).status === 404) { setNotFoundMessage("Script code could not be found."); return; diff --git a/src/qqq/pages/processes/ProcessRun.tsx b/src/qqq/pages/processes/ProcessRun.tsx index b383d26..4148082 100644 --- a/src/qqq/pages/processes/ProcessRun.tsx +++ b/src/qqq/pages/processes/ProcessRun.tsx @@ -1079,7 +1079,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is const handlePermissionDenied = (e: any): boolean => { - if ((e as QException).status === "403") + if ((e as QException).status === 403) { setProcessError(`You do not have permission to run this ${isReport ? "report" : "process"}.`, true); return (true); diff --git a/src/qqq/pages/records/view/RecordDeveloperView.tsx b/src/qqq/pages/records/view/RecordDeveloperView.tsx index cec5bf7..11e1efb 100644 --- a/src/qqq/pages/records/view/RecordDeveloperView.tsx +++ b/src/qqq/pages/records/view/RecordDeveloperView.tsx @@ -121,7 +121,7 @@ function RecordDeveloperView({table}: Props): JSX.Element { if (e instanceof QException) { - if ((e as QException).status === "404") + if ((e as QException).status === 404) { setNotFoundMessage(`${tableMetaData.label} ${id} could not be found.`); return; diff --git a/src/qqq/utils/qqq/Client.ts b/src/qqq/utils/qqq/Client.ts index d75aa08..0c008fc 100644 --- a/src/qqq/utils/qqq/Client.ts +++ b/src/qqq/utils/qqq/Client.ts @@ -35,7 +35,7 @@ class Client { console.log(`Caught Exception: ${JSON.stringify(exception)}`); - if(exception && exception.status == "401" && Client.unauthorizedCallback) + if(exception && exception.status == 401 && Client.unauthorizedCallback) { console.log("This is a 401 - calling the unauthorized callback."); Client.unauthorizedCallback(); From 2c0725852ed7a1382d1ed0240f8feb07bddb6587 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 11 Apr 2024 10:59:37 -0500 Subject: [PATCH 19/22] CE-1115 change exception status to numbers per qfc, axios update --- src/qqq/components/audits/AuditBody.tsx | 4 ++-- src/qqq/pages/records/view/RecordView.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/qqq/components/audits/AuditBody.tsx b/src/qqq/components/audits/AuditBody.tsx index 3d4b4a2..f87b6a6 100644 --- a/src/qqq/components/audits/AuditBody.tsx +++ b/src/qqq/components/audits/AuditBody.tsx @@ -34,10 +34,10 @@ import ToggleButton from "@mui/material/ToggleButton"; import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; -import React, {useContext, useEffect, useState} from "react"; import QContext from "QContext"; import Client from "qqq/utils/qqq/Client"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; +import React, {useContext, useEffect, useState} from "react"; interface Props { @@ -217,7 +217,7 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element { if (e instanceof QException) { - if ((e as QException).status === "403") + if ((e as QException).status === 403) { setStatusString("You do not have permission to view audits"); return; diff --git a/src/qqq/pages/records/view/RecordView.tsx b/src/qqq/pages/records/view/RecordView.tsx index 7b24602..455ac6b 100644 --- a/src/qqq/pages/records/view/RecordView.tsx +++ b/src/qqq/pages/records/view/RecordView.tsx @@ -447,13 +447,13 @@ function RecordView({table, launchProcess}: Props): JSX.Element if (e instanceof QException) { - if ((e as QException).status === "404") + if ((e as QException).status === 404) { setNotFoundMessage(`${tableMetaData.label} ${id} could not be found.`); historyPurge(location.pathname); return; } - else if ((e as QException).status === "403") + else if ((e as QException).status === 403) { setNotFoundMessage(`You do not have permission to view ${tableMetaData.label} records`); historyPurge(location.pathname); From eafd8d98cd89a4b34427caabd7e174292a85565a Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Sun, 14 Apr 2024 20:10:29 -0500 Subject: [PATCH 20/22] CE-1115 pre-QA commit on saved report UI, including: - redo pivots so editing is in a modal - add form validations - field rules for clearing one field when another changes --- .../MaterialDashboardTableMetaData.java | 90 ++- .../model/metadata/fieldrules/FieldRule.java | 165 +++++ .../metadata/fieldrules/FieldRuleAction.java | 31 + .../metadata/fieldrules/FieldRuleTrigger.java | 31 + ...ableFrontendMaterialDashboardEnricher.java | 59 ++ src/qqq/components/forms/EntityForm.tsx | 43 +- src/qqq/components/misc/FieldAutoComplete.tsx | 42 +- src/qqq/components/misc/SavedViews.tsx | 48 +- .../components/query/AdvancedQueryPreview.tsx | 2 +- .../components/query/FilterCriteriaRow.tsx | 6 +- src/qqq/components/widgets/Widget.tsx | 19 +- .../widgets/misc/PivotTableGroupByElement.tsx | 8 +- .../widgets/misc/PivotTableSetupWidget.tsx | 572 ++++++++++++------ .../widgets/misc/PivotTableValueElement.tsx | 135 +++-- .../widgets/misc/ReportSetupWidget.tsx | 39 +- src/qqq/models/fields/FieldRules.ts | 50 ++ .../models/misc/PivotTableDefinitionModels.ts | 64 +- src/qqq/models/query/QQueryColumns.ts | 46 +- src/qqq/pages/records/query/RecordQuery.tsx | 15 +- 19 files changed, 1168 insertions(+), 297 deletions(-) create mode 100644 src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRule.java create mode 100644 src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRuleAction.java create mode 100644 src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRuleTrigger.java create mode 100644 src/main/java/com/kingsrook/qqq/frontend/materialdashboard/savedreports/SavedReportTableFrontendMaterialDashboardEnricher.java create mode 100644 src/qqq/models/fields/FieldRules.ts diff --git a/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaData.java b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaData.java index d40cdb0..96ff230 100644 --- a/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaData.java +++ b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaData.java @@ -22,6 +22,7 @@ package com.kingsrook.qqq.frontend.materialdashboard.model.metadata; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -30,6 +31,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRule; /******************************************************************************* @@ -37,8 +40,11 @@ import com.kingsrook.qqq.backend.core.utils.CollectionUtils; *******************************************************************************/ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData { + public static final String TYPE = "materialDashboard"; + private List> gotoFieldNames; - private List defaultQuickFilterFieldNames; + private List defaultQuickFilterFieldNames; + private List fieldRules; /******************************************************************************* @@ -58,10 +64,25 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData @Override public String getType() { - return ("materialDashboard"); + return (TYPE); } + /******************************************************************************* + ** + *******************************************************************************/ + public static MaterialDashboardTableMetaData ofOrWithNew(QTableMetaData table) + { + MaterialDashboardTableMetaData supplementalMetaData = (MaterialDashboardTableMetaData) table.getSupplementalMetaData(TYPE); + if(supplementalMetaData == null) + { + supplementalMetaData = new MaterialDashboardTableMetaData(); + table.withSupplementalMetaData(supplementalMetaData); + } + + return (supplementalMetaData); + } + /******************************************************************************* ** Getter for gotoFieldNames @@ -110,6 +131,22 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData validateListOfFieldNames(tableMetaData, gotoFieldNameSubList, qInstanceValidator, prefix + "gotoFieldNames: "); } validateListOfFieldNames(tableMetaData, defaultQuickFilterFieldNames, qInstanceValidator, prefix + "defaultQuickFilterFieldNames: "); + + for(FieldRule fieldRule : CollectionUtils.nonNullList(fieldRules)) + { + qInstanceValidator.assertCondition(fieldRule.getTrigger() != null, prefix + "has a fieldRule without a trigger"); + qInstanceValidator.assertCondition(fieldRule.getAction() != null, prefix + "has a fieldRule without an action"); + + if(qInstanceValidator.assertCondition(StringUtils.hasContent(fieldRule.getSourceField()), prefix + "has a fieldRule without a sourceField")) + { + qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldRule.getSourceField()), prefix + "has a fieldRule with an unrecognized sourceField: " + fieldRule.getSourceField()); + } + + if(qInstanceValidator.assertCondition(StringUtils.hasContent(fieldRule.getTargetField()), prefix + "has a fieldRule without a targetField")) + { + qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldRule.getTargetField()), prefix + "has a fieldRule with an unrecognized targetField: " + fieldRule.getTargetField()); + } + } } @@ -124,7 +161,7 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData { if(qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldName), prefix + " unrecognized field name: " + fieldName)) { - qInstanceValidator.assertCondition(!usedNames.contains(fieldName), prefix + " has a duplicated field name: " + fieldName); + qInstanceValidator.assertCondition(!usedNames.contains(fieldName), prefix + "has a duplicated field name: " + fieldName); usedNames.add(fieldName); } } @@ -161,4 +198,51 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData return (this); } + + /******************************************************************************* + ** Getter for fieldRules + *******************************************************************************/ + public List getFieldRules() + { + return (this.fieldRules); + } + + + + /******************************************************************************* + ** Setter for fieldRules + *******************************************************************************/ + public void setFieldRules(List fieldRules) + { + this.fieldRules = fieldRules; + } + + + + /******************************************************************************* + ** Fluent setter for fieldRules + *******************************************************************************/ + public MaterialDashboardTableMetaData withFieldRules(List fieldRules) + { + this.fieldRules = fieldRules; + return (this); + } + + + + /******************************************************************************* + ** Fluent setter for fieldRules + *******************************************************************************/ + public MaterialDashboardTableMetaData withFieldRule(FieldRule fieldRule) + { + if(this.fieldRules == null) + { + this.fieldRules = new ArrayList<>(); + } + + this.fieldRules.add(fieldRule); + + return (this); + } + } diff --git a/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRule.java b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRule.java new file mode 100644 index 0000000..a4722eb --- /dev/null +++ b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRule.java @@ -0,0 +1,165 @@ +/* + * 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules; + + +import java.io.Serializable; + + +/******************************************************************************* + ** definition of rules for how UI fields should behave. + ** + ** e.g., one field being changed causing different things to be needed in another + ** field. + *******************************************************************************/ +public class FieldRule implements Serializable +{ + private FieldRuleTrigger trigger; + private String sourceField; + private FieldRuleAction action; + private String targetField; + + + + /******************************************************************************* + ** Getter for trigger + *******************************************************************************/ + public FieldRuleTrigger getTrigger() + { + return (this.trigger); + } + + + + /******************************************************************************* + ** Setter for trigger + *******************************************************************************/ + public void setTrigger(FieldRuleTrigger trigger) + { + this.trigger = trigger; + } + + + + /******************************************************************************* + ** Fluent setter for trigger + *******************************************************************************/ + public FieldRule withTrigger(FieldRuleTrigger trigger) + { + this.trigger = trigger; + return (this); + } + + + + /******************************************************************************* + ** Getter for sourceField + *******************************************************************************/ + public String getSourceField() + { + return (this.sourceField); + } + + + + /******************************************************************************* + ** Setter for sourceField + *******************************************************************************/ + public void setSourceField(String sourceField) + { + this.sourceField = sourceField; + } + + + + /******************************************************************************* + ** Fluent setter for sourceField + *******************************************************************************/ + public FieldRule withSourceField(String sourceField) + { + this.sourceField = sourceField; + return (this); + } + + + + /******************************************************************************* + ** Getter for action + *******************************************************************************/ + public FieldRuleAction getAction() + { + return (this.action); + } + + + + /******************************************************************************* + ** Setter for action + *******************************************************************************/ + public void setAction(FieldRuleAction action) + { + this.action = action; + } + + + + /******************************************************************************* + ** Fluent setter for action + *******************************************************************************/ + public FieldRule withAction(FieldRuleAction action) + { + this.action = action; + return (this); + } + + + + /******************************************************************************* + ** Getter for targetField + *******************************************************************************/ + public String getTargetField() + { + return (this.targetField); + } + + + + /******************************************************************************* + ** Setter for targetField + *******************************************************************************/ + public void setTargetField(String targetField) + { + this.targetField = targetField; + } + + + + /******************************************************************************* + ** Fluent setter for targetField + *******************************************************************************/ + public FieldRule withTargetField(String targetField) + { + this.targetField = targetField; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRuleAction.java b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRuleAction.java new file mode 100644 index 0000000..cc112c0 --- /dev/null +++ b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRuleAction.java @@ -0,0 +1,31 @@ +/* + * 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules; + + +/******************************************************************************* + ** possible actions associated with field rules + *******************************************************************************/ +public enum FieldRuleAction +{ + CLEAR_TARGET_FIELD +} diff --git a/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRuleTrigger.java b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRuleTrigger.java new file mode 100644 index 0000000..53d747e --- /dev/null +++ b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRuleTrigger.java @@ -0,0 +1,31 @@ +/* + * 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules; + + +/******************************************************************************* + ** possible triggers associated with field rules + *******************************************************************************/ +public enum FieldRuleTrigger +{ + ON_CHANGE +} diff --git a/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/savedreports/SavedReportTableFrontendMaterialDashboardEnricher.java b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/savedreports/SavedReportTableFrontendMaterialDashboardEnricher.java new file mode 100644 index 0000000..5827812 --- /dev/null +++ b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/savedreports/SavedReportTableFrontendMaterialDashboardEnricher.java @@ -0,0 +1,59 @@ +/* + * 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.savedreports; + + +import java.util.List; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.MaterialDashboardTableMetaData; +import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRule; +import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRuleAction; +import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRuleTrigger; + + +/******************************************************************************* + ** Add frontend material dashboard enhacements to saved report table + *******************************************************************************/ +public class SavedReportTableFrontendMaterialDashboardEnricher +{ + + /******************************************************************************* + ** + *******************************************************************************/ + public static void enrich(QTableMetaData tableMetaData) + { + MaterialDashboardTableMetaData materialDashboardTableMetaData = MaterialDashboardTableMetaData.ofOrWithNew(tableMetaData); + + ///////////////////////////////////////////////////////////////////////// + // make changes to the tableName field clear the value in these fields // + ///////////////////////////////////////////////////////////////////////// + for(String targetField : List.of("queryFilterJson", "columnsJson", "pivotTableJson")) + { + materialDashboardTableMetaData.withFieldRule(new FieldRule() + .withSourceField("tableName") + .withTrigger(FieldRuleTrigger.ON_CHANGE) + .withAction(FieldRuleAction.CLEAR_TARGET_FIELD) + .withTargetField(targetField)); + } + } + +} diff --git a/src/qqq/components/forms/EntityForm.tsx b/src/qqq/components/forms/EntityForm.tsx index 6a1cd45..54f7b1b 100644 --- a/src/qqq/components/forms/EntityForm.tsx +++ b/src/qqq/components/forms/EntityForm.tsx @@ -46,6 +46,7 @@ import QRecordSidebar from "qqq/components/misc/RecordSidebar"; import PivotTableSetupWidget from "qqq/components/widgets/misc/PivotTableSetupWidget"; import RecordGridWidget, {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget"; import ReportSetupWidget from "qqq/components/widgets/misc/ReportSetupWidget"; +import {FieldRule, FieldRuleAction, FieldRuleTrigger} from "qqq/models/fields/FieldRules"; import HtmlUtils from "qqq/utils/HtmlUtils"; import Client from "qqq/utils/qqq/Client"; import TableUtils from "qqq/utils/qqq/TableUtils"; @@ -108,6 +109,7 @@ function EntityForm(props: Props): JSX.Element const [asyncLoadInited, setAsyncLoadInited] = useState(false); const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData); + const [fieldRules, setFieldRules] = useState([] as FieldRule[]); const [metaData, setMetaData] = useState(null as QInstance); const [record, setRecord] = useState(null as QRecord); const [tableSections, setTableSections] = useState(null as QTableSection[]); @@ -427,6 +429,32 @@ function EntityForm(props: Props): JSX.Element } + /******************************************************************************* + ** + *******************************************************************************/ + function setupFieldRules(tableMetaData: QTableMetaData) + { + const mdbMetaData = tableMetaData?.supplementalTableMetaData?.get("materialDashboard"); + if(!mdbMetaData) + { + return; + } + + if(mdbMetaData.fieldRules) + { + const newFieldRules: FieldRule[] = []; + for (let i = 0; i < mdbMetaData.fieldRules.length; i++) + { + newFieldRules.push(mdbMetaData.fieldRules[i]); + } + setFieldRules(newFieldRules); + } + } + + + ////////////////// + // initial load // + ////////////////// if (!asyncLoadInited) { setAsyncLoadInited(true); @@ -435,6 +463,8 @@ function EntityForm(props: Props): JSX.Element const tableMetaData = await qController.loadTableMetaData(tableName); setTableMetaData(tableMetaData); + setupFieldRules(tableMetaData); + const metaData = await qController.loadMetaData(); setMetaData(metaData); @@ -929,15 +959,6 @@ function EntityForm(props: Props): JSX.Element }; - // todo - get from meta data! - const fieldRules = - [ - {trigger: "onChange", sourceField: "tableName", action: "clearOtherField", targetField: "columnsJson"}, - {trigger: "onChange", sourceField: "tableName", action: "clearOtherField", targetField: "queryFilterJson"}, - {trigger: "onChange", sourceField: "tableName", action: "clearOtherField", targetField: "pivotTableJson"} - ] - - /******************************************************************************* ** process a form-field having a changed value (e.g., apply field rules). *******************************************************************************/ @@ -945,11 +966,11 @@ function EntityForm(props: Props): JSX.Element { for (let fieldRule of fieldRules) { - if(fieldRule.trigger == "onChange" && fieldRule.sourceField == fieldName) + if(fieldRule.trigger == FieldRuleTrigger.ON_CHANGE && fieldRule.sourceField == fieldName) { switch (fieldRule.action) { - case "clearOtherField": + case FieldRuleAction.CLEAR_TARGET_FIELD: console.log(`Clearing value from [${fieldRule.targetField}] due to change in [${fieldName}]`); valueChangesToMake[fieldRule.targetField] = null; break; diff --git a/src/qqq/components/misc/FieldAutoComplete.tsx b/src/qqq/components/misc/FieldAutoComplete.tsx index 495f831..7f5e642 100644 --- a/src/qqq/components/misc/FieldAutoComplete.tsx +++ b/src/qqq/components/misc/FieldAutoComplete.tsx @@ -23,7 +23,9 @@ import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; 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"; import Autocomplete, {AutocompleteRenderOptionState} from "@mui/material/Autocomplete"; +import Icon from "@mui/material/Icon"; import TextField from "@mui/material/TextField"; import React, {ReactNode, useState} from "react"; @@ -33,14 +35,16 @@ interface FieldAutoCompleteProps metaData: QInstance; tableMetaData: QTableMetaData; handleFieldChange: (event: any, newValue: any, reason: string) => void; - defaultValue?: {field: QFieldMetaData, table: QTableMetaData, fieldName: string}; + defaultValue?: { field: QFieldMetaData, table: QTableMetaData, fieldName: string }; autoFocus?: boolean; forceOpen?: boolean; hiddenFieldNames?: string[]; availableFieldNames?: string[]; - variant?: "standard" | "filled" | "outlined" - label?: string - textFieldSX?: any + variant?: "standard" | "filled" | "outlined"; + label?: string; + textFieldSX?: any; + autocompleteSlotProps?: any; + hasError?: boolean; } FieldAutoComplete.defaultProps = @@ -53,6 +57,8 @@ FieldAutoComplete.defaultProps = variant: "standard", label: "Field", textFieldSX: null, + autocompleteSlotProps: null, + hasError: false, }; function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[], availableFieldNames: string[], selectedFieldName: string) @@ -62,12 +68,12 @@ function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: a { const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name; - if(hiddenFieldNames && hiddenFieldNames.indexOf(fieldName) > -1 && fieldName != selectedFieldName) + if (hiddenFieldNames && hiddenFieldNames.indexOf(fieldName) > -1 && fieldName != selectedFieldName) { continue; } - if(availableFieldNames && availableFieldNames.indexOf(fieldName) == -1) + if (availableFieldNames && availableFieldNames.indexOf(fieldName) == -1) { continue; } @@ -80,9 +86,9 @@ function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: a /******************************************************************************* ** Component for rendering a list of field names from a table as an auto-complete. *******************************************************************************/ -export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames, availableFieldNames, variant, label, textFieldSX}: FieldAutoCompleteProps): JSX.Element +export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames, availableFieldNames, variant, label, textFieldSX, autocompleteSlotProps, hasError}: FieldAutoCompleteProps): JSX.Element { - const [selectedFieldName, setSelectedFieldName] = useState(defaultValue ? defaultValue.fieldName : null) + const [selectedFieldName, setSelectedFieldName] = useState(defaultValue ? defaultValue.fieldName : null); const fieldOptions: any[] = []; makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames, availableFieldNames, selectedFieldName); @@ -149,8 +155,8 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi // seems like, if we always add the open attribute, then if its false or null, then the autocomplete // // doesn't open at all... so, only add the attribute at all, if forceOpen is true // /////////////////////////////////////////////////////////////////////////////////////////////////////// - const alsoOpen: {[key: string]: any} = {} - if(forceOpen) + const alsoOpen: { [key: string]: any } = {}; + if (forceOpen) { alsoOpen["open"] = forceOpen; } @@ -161,14 +167,24 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi *******************************************************************************/ function onChange(event: any, newValue: any, reason: string) { - setSelectedFieldName(newValue ? newValue.fieldName : null) + setSelectedFieldName(newValue ? newValue.fieldName : null); handleFieldChange(event, newValue, reason); } return ( ()} + renderInput={(params) => + { + const inputProps = params.InputProps; + const originalEndAdornment = inputProps.endAdornment; + inputProps.endAdornment = + {hasError && error_outline} + {originalEndAdornment} + ; + + return () + }} // @ts-ignore defaultValue={defaultValue} options={fieldOptions} @@ -179,7 +195,7 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi renderOption={(props, option, state) => renderFieldOption(props, option, state)} autoSelect={true} autoHighlight={true} - slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}} + slotProps={autocompleteSlotProps ?? {}} {...alsoOpen} /> diff --git a/src/qqq/components/misc/SavedViews.tsx b/src/qqq/components/misc/SavedViews.tsx index 000cca6..49d7ef6 100644 --- a/src/qqq/components/misc/SavedViews.tsx +++ b/src/qqq/components/misc/SavedViews.tsx @@ -431,9 +431,12 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab open={Boolean(savedViewsMenu)} onClose={closeSavedViewsMenu} keepMounted - PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minHeight: "200px"}}} + PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minWidth: "300px"}}} > - View Actions + { + isQueryScreen && + View Actions + } { isQueryScreen && hasStorePermission && Save your current filters, columns and settings, for quick re-use at a later time.

You will be prompted to enter a name if you choose this option.}> @@ -471,6 +474,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
} { + isQueryScreen && handleDropdownOptionClick(CLEAR_OPTION)}> monitor @@ -479,7 +483,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab } { - hasSavedReportsPermission && + isQueryScreen && hasSavedReportsPermission && handleDropdownOptionClick(NEW_REPORT_OPTION)}> article @@ -487,7 +491,9 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab } - + { + isQueryScreen && + } Your Saved Views { savedViews && savedViews.length > 0 ? ( @@ -497,7 +503,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
) ): ( - + You do not have any saved views for this table. ) @@ -606,7 +612,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab } }> - +
{/* vertical rule */} @@ -618,7 +624,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab } { - currentSavedView && viewIsModified && <> + isQueryScreen && currentSavedView && viewIsModified && <> Unsaved Changes
    @@ -637,6 +643,34 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab } + { + !isQueryScreen && currentSavedView && + + + {currentSavedView.values.get("label")} + + + { + viewIsModified && + <> + + Changes +
      + { + viewDiffs.map((s: string, i: number) =>
    • {s}
    • ) + } +
    }> + with {viewDiffs.length} Change{viewDiffs.length == 1 ? "" : "s"} +
    + + + } + + {/* vertical rule */} + + + + }
    { diff --git a/src/qqq/components/query/AdvancedQueryPreview.tsx b/src/qqq/components/query/AdvancedQueryPreview.tsx index 1ea81de..f951da2 100644 --- a/src/qqq/components/query/AdvancedQueryPreview.tsx +++ b/src/qqq/components/query/AdvancedQueryPreview.tsx @@ -138,7 +138,7 @@ export default function AdvancedQueryPreview({tableMetaData, queryFilter, isEdit display="inline-block" width="100%" sx={{fontSize: "1rem", background: "#FFFFFF"}} - minHeight={"2.375rem"} + minHeight={"2.5rem"} p={"0.5rem"} pb={"0.125rem"} {...moreSX} diff --git a/src/qqq/components/query/FilterCriteriaRow.tsx b/src/qqq/components/query/FilterCriteriaRow.tsx index c792b38..ff720a2 100644 --- a/src/qqq/components/query/FilterCriteriaRow.tsx +++ b/src/qqq/components/query/FilterCriteriaRow.tsx @@ -33,10 +33,10 @@ import MenuItem from "@mui/material/MenuItem"; import Select, {SelectChangeEvent} from "@mui/material/Select/Select"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; -import React, {ReactNode, SyntheticEvent, useState} from "react"; import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; +import React, {ReactNode, SyntheticEvent, useState} from "react"; export enum ValueMode @@ -484,7 +484,9 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, : } - + diff --git a/src/qqq/components/widgets/Widget.tsx b/src/qqq/components/widgets/Widget.tsx index ccf0d45..8756d15 100644 --- a/src/qqq/components/widgets/Widget.tsx +++ b/src/qqq/components/widgets/Widget.tsx @@ -206,9 +206,16 @@ interface HeaderToggleComponentProps label: string; getValue: () => boolean; onClickCallback: () => void; + disabled?: boolean; + disabledTooltip?: string; } -export function HeaderToggleComponent({label, getValue, onClickCallback}: HeaderToggleComponentProps): JSX.Element +HeaderToggleComponent.defaultProps = { + disabled: false, + disabledTooltip: null +}; + +export function HeaderToggleComponent({label, getValue, onClickCallback, disabled, disabledTooltip}: HeaderToggleComponentProps): JSX.Element { const onClick = () => { @@ -217,9 +224,13 @@ export function HeaderToggleComponent({label, getValue, onClickCallback}: Header return ( - - {label} - + + + + {label} + + + ); } diff --git a/src/qqq/components/widgets/misc/PivotTableGroupByElement.tsx b/src/qqq/components/widgets/misc/PivotTableGroupByElement.tsx index 56454aa..9e961e2 100644 --- a/src/qqq/components/widgets/misc/PivotTableGroupByElement.tsx +++ b/src/qqq/components/widgets/misc/PivotTableGroupByElement.tsx @@ -51,6 +51,7 @@ export interface PivotTableGroupByElementProps groupBy: PivotTableGroupBy; rowsOrColumns: "rows" | "columns"; callback: () => void; + attemptedSubmit?: boolean; } @@ -67,7 +68,7 @@ interface DragItem /******************************************************************************* ** *******************************************************************************/ -export const PivotTableGroupByElement: FC = ({id, index, dragCallback, rowsOrColumns, metaData, tableMetaData, pivotTableDefinition, groupBy, usedGroupByFieldNames, availableFieldNames, isEditable, callback}) => +export const PivotTableGroupByElement: FC = ({id, index, dragCallback, rowsOrColumns, metaData, tableMetaData, pivotTableDefinition, groupBy, usedGroupByFieldNames, availableFieldNames, isEditable, callback, attemptedSubmit}) => { //////////////////////////////////////////////////////////////////////////// // credit: https://react-dnd.github.io/react-dnd/examples/sortable/simple // @@ -171,7 +172,7 @@ export const PivotTableGroupByElement: FC = ({id, if (selectedField) { const label = selectedField.table.name == tableMetaData.name ? selectedField.field.label : selectedField.table.label + ": " + selectedField.field.label; - return ({label}); + return ({label}); } return (); @@ -179,6 +180,8 @@ export const PivotTableGroupByElement: FC = ({id, preview(drop(ref)); + const showError = attemptedSubmit && !groupBy.fieldName; + return ( drag_indicator @@ -195,6 +198,7 @@ export const PivotTableGroupByElement: FC = ({id, hiddenFieldNames={usedGroupByFieldNames} availableFieldNames={availableFieldNames} defaultValue={getSelectedFieldForAutoComplete(tableMetaData, groupBy.fieldName)} + hasError={showError} /> diff --git a/src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx b/src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx index c7aa642..60370ea 100644 --- a/src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx +++ b/src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx @@ -23,19 +23,25 @@ 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"; @@ -52,22 +58,6 @@ export const DragItemTypes = 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`, @@ -139,11 +129,20 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor 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); - const [pivotTableDefinition, setPivotTableDefinition] = useState(null as PivotTableDefinition); + /////////////////////////////////////////////////////////////////////////////////// + // 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 [availableFieldNames, setAvailableFieldNames] = useState([] as string[]); @@ -195,9 +194,9 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor updateUsedGroupByFieldNames(originalPivotTableDefinition); } - if(recordValues["columnsJson"]) + if (recordValues["columnsJson"]) { - updateAvailableFieldNames(JSON.parse(recordValues["columnsJson"]) as QQueryColumns) + updateAvailableFieldNames(JSON.parse(recordValues["columnsJson"]) as QQueryColumns); } (async () => @@ -251,6 +250,11 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor const newEnabled = !!!getEnabled(); setEnabled(newEnabled); onSaveCallback({usePivotTable: newEnabled}); + + if (!newEnabled) + { + onSaveCallback({pivotTableJson: null}); + } } @@ -268,13 +272,13 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor *******************************************************************************/ function addGroupBy(rowsOrColumns: "rows" | "columns") { - if (!pivotTableDefinition[rowsOrColumns]) + if (!modalPivotTableDefinition[rowsOrColumns]) { - pivotTableDefinition[rowsOrColumns] = []; + modalPivotTableDefinition[rowsOrColumns] = []; } - pivotTableDefinition[rowsOrColumns].push(new PivotTableGroupBy()); - onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); + modalPivotTableDefinition[rowsOrColumns].push(new PivotTableGroupBy()); + validateForm() forceUpdate(); } @@ -284,8 +288,8 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor *******************************************************************************/ function groupByChangedCallback() { - onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); - updateUsedGroupByFieldNames(); + updateUsedGroupByFieldNames(modalPivotTableDefinition); + validateForm() forceUpdate(); } @@ -295,13 +299,13 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor *******************************************************************************/ function addValue() { - if (!pivotTableDefinition.values) + if (!modalPivotTableDefinition.values) { - pivotTableDefinition.values = []; + modalPivotTableDefinition.values = []; } - pivotTableDefinition.values.push(new PivotTableValue()); - onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); + modalPivotTableDefinition.values.push(new PivotTableValue()); + validateForm() forceUpdate(); } @@ -311,8 +315,8 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor *******************************************************************************/ function removeValue(index: number) { - pivotTableDefinition.values.splice(index, 1); - onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); + modalPivotTableDefinition.values.splice(index, 1); + validateForm() forceUpdate(); } @@ -346,7 +350,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor const fieldNames: string[] = []; for (let i = 0; i < columns?.columns?.length; i++) { - if(columns.columns[i].isVisible) + if (columns.columns[i].isVisible) { fieldNames.push(columns.columns[i].name); } @@ -375,13 +379,11 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor 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[] = []; @@ -446,14 +448,13 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor *******************************************************************************/ const moveGroupBy = useCallback((rowsOrColumns: "rows" | "columns", dragIndex: number, hoverIndex: number) => { - const array = pivotTableDefinition[rowsOrColumns]; + const array = modalPivotTableDefinition[rowsOrColumns]; const dragItem = array[dragIndex]; array.splice(dragIndex, 1); array.splice(hoverIndex, 0, dragItem); - onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); forceUpdate(); - }, [pivotTableDefinition]); + }, [modalPivotTableDefinition]); /******************************************************************************* @@ -461,183 +462,388 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor *******************************************************************************/ const moveValue = useCallback((dragIndex: number, hoverIndex: number) => { - const array = pivotTableDefinition.values; + const array = modalPivotTableDefinition.values; const dragItem = array[dragIndex]; array.splice(dragIndex, 1); array.splice(hoverIndex, 0, dragItem); - onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); forceUpdate(); - }, [pivotTableDefinition]); + }, [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} />); + labelAdditionalElementsRight.push( enabled} onClickCallback={toggleEnabled} />); } - 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], + 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) => - { - return ( - - ); - }, - [tableMetaData, usedGroupByFieldNames, availableFieldNames], + const renderValue = useCallback((value: PivotTableValue, index: number, forModal: boolean) => + { + return ( + + ); + }, + [tableMetaData, usedGroupByFieldNames, availableFieldNames], ); - return ( - {enabled && pivotTableDefinition && - + /******************************************************************************* + ** + *******************************************************************************/ + 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"}
    + { - showHelp("sectionSubhead") && - - {getHelpContent("sectionSubhead")} - + tableMetaData && (
    {ptd[rowsOrColumns]?.map((groupBy, i) => renderGroupBy(groupBy, rowsOrColumns, i, forModal))}
    ) } - - - -
    Rows
    - - { - tableMetaData && (
    {pivotTableDefinition?.rows?.map((row, i) => renderGroupBy(row, "rows", i))}
    ) - } -
    - { - isEditable && - - - - - - } -
    - - -
    Columns
    - - { - tableMetaData && (
    {pivotTableDefinition?.columns?.map((column, i) => renderGroupBy(column, "columns", i))}
    ) - } -
    - { - isEditable && - - - - - - } -
    - - -
    Values
    - - { - tableMetaData && (
    {pivotTableDefinition?.values?.map((value, i) => renderValue(value, i))}
    ) - } -
    - { - isEditable && - - - - - - } -
    - -
    - {/* - -
    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}
    +
    + { + (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); + + 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}
    +
    +*/ diff --git a/src/qqq/components/widgets/misc/PivotTableValueElement.tsx b/src/qqq/components/widgets/misc/PivotTableValueElement.tsx index 38c6704..e99fe49 100644 --- a/src/qqq/components/widgets/misc/PivotTableValueElement.tsx +++ b/src/qqq/components/widgets/misc/PivotTableValueElement.tsx @@ -20,6 +20,8 @@ */ +import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; +import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; 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"; @@ -31,8 +33,8 @@ 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 {functionsPerFieldType, PivotTableDefinition, pivotTableFunctionLabels, PivotTableValue} from "qqq/models/misc/PivotTableDefinitionModels"; +import React, {FC, useReducer, useRef, useState} from "react"; import {useDrag, useDrop} from "react-dnd"; @@ -51,6 +53,7 @@ export interface PivotTableValueElementProps isEditable: boolean; value: PivotTableValue; callback: () => void; + attemptedSubmit?: boolean; } @@ -68,8 +71,11 @@ interface DragItem /******************************************************************************* ** Element to render 1 pivot-table value. *******************************************************************************/ -export const PivotTableValueElement: FC = ({id, index, dragCallback, metaData, tableMetaData, pivotTableDefinition, availableFieldNames, value, isEditable, callback}) => +export const PivotTableValueElement: FC = ({id, index, dragCallback, metaData, tableMetaData, pivotTableDefinition, availableFieldNames, value, isEditable, callback, attemptedSubmit}) => { + const [defaultFunctionValue, setDefaultFunctionValue] = useState(null); + const [, forceUpdate] = useReducer((x) => x + 1, 0); + //////////////////////////////////////////////////////////////////////////// // credit: https://react-dnd.github.io/react-dnd/examples/sortable/simple // //////////////////////////////////////////////////////////////////////////// @@ -147,24 +153,68 @@ export const PivotTableValueElement: FC = ({id, ind }); + /******************************************************************************* + ** + *******************************************************************************/ + function getFunctionsForField(field: QFieldMetaData) + { + if(field) + { + let type = field.type; + if (field.possibleValueSourceName) + { + type = QFieldType.STRING; + } + + if(functionsPerFieldType[type]) + { + return (functionsPerFieldType[type]); + } + } + + ////////////////////////////////////// + // return broadest list if no field // + ////////////////////////////////////// + return (functionsPerFieldType[QFieldType.INTEGER]); + } + + /******************************************************************************* ** event handler for user selecting a field *******************************************************************************/ - const handleFieldChange = (event: any, newValue: any, reason: string) => + function handleFieldChange(event: any, newValue: any, reason: string) { value.fieldName = newValue ? newValue.fieldName : null; + + if(newValue) + { + ///////////////////////////////////////////////////////////////////////////////////////// + // if newly selected field doesn't have the currently selected function, then clear it // + ///////////////////////////////////////////////////////////////////////////////////////// + const newSelectedField = getSelectedFieldForAutoComplete(tableMetaData, newValue.fieldName); + if (newSelectedField) + { + if(getFunctionsForField(newSelectedField.field).indexOf(value.function) == -1) + { + setDefaultFunctionValue(null); + handleFunctionChange(null, null, null); + forceUpdate(); + } + } + } + callback(); - }; + } /******************************************************************************* ** event handler for user selecting a function *******************************************************************************/ - const handleFunctionChange = (event: any, newValue: any, reason: string) => + function handleFunctionChange(event: any, newValue: any, reason: string) { value.function = newValue ? newValue.id : null; callback(); - }; + } /******************************************************************************* @@ -176,65 +226,43 @@ export const PivotTableValueElement: FC = ({id, ind callback(); } + const selectedField = getSelectedFieldForAutoComplete(tableMetaData, value.fieldName); ///////////////////////////////////////////////////////////////////// // if we're not on an edit screen, return a simpler read-only view // ///////////////////////////////////////////////////////////////////// if (!isEditable) { - const selectedField = getSelectedFieldForAutoComplete(tableMetaData, value.fieldName); + let label = "--"; 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}); + label = pivotTableFunctionLabels[value.function] + " of " + (selectedField.table.name == tableMetaData.name ? selectedField.field.label : selectedField.table.label + ": " + selectedField.field.label); } - return (); + return ({label}); } /////////////////////////////////////////////////////////////////////////////// // figure out functions to display in drop down, plus selected/default value // /////////////////////////////////////////////////////////////////////////////// const functionOptions: any[] = []; - let defaultFunctionValue = null; - for (let pivotTableFunctionKey in PivotTableFunction) + const availableFunctions = getFunctionsForField(selectedField?.field); + for (let pivotTableFunction of availableFunctions) { - // @ts-ignore any? - const label = "" + pivotTableFunctionLabels[pivotTableFunctionKey]; - const option = {id: pivotTableFunctionKey, label: label}; + const label = pivotTableFunctionLabels[pivotTableFunction]; + const option = {id: pivotTableFunction, label: label}; functionOptions.push(option); - if (option.id == value.function) + if (option.id == value.function && JSON.stringify(option) != JSON.stringify(defaultFunctionValue)) { - defaultFunctionValue = option; + setDefaultFunctionValue(option); } } drag(drop(ref)); - /* - return ( - - drag_indicator - - - - - - - - ); - */ + const showValueError = attemptedSubmit && !value.fieldName; + const showFunctionError = attemptedSubmit && !value.function; return ( @@ -250,25 +278,34 @@ export const PivotTableValueElement: FC = ({id, ind tableMetaData={tableMetaData} handleFieldChange={handleFieldChange} availableFieldNames={availableFieldNames} - defaultValue={getSelectedFieldForAutoComplete(tableMetaData, value.fieldName)} + defaultValue={selectedField} + hasError={showValueError} /> - + ()} + id={`values-function-${index}`} + renderInput={(params) => + { + const inputProps = params.InputProps; + const originalEndAdornment = inputProps.endAdornment; + inputProps.endAdornment = + {showFunctionError && error_outline} + {originalEndAdornment} + ; + + return () + }} // @ts-ignore - defaultValue={defaultFunctionValue} + value={defaultFunctionValue} + inputValue={defaultFunctionValue?.label ?? ""} 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 39eb8f6..9cdf995 100644 --- a/src/qqq/components/widgets/misc/ReportSetupWidget.tsx +++ b/src/qqq/components/widgets/misc/ReportSetupWidget.tsx @@ -25,8 +25,8 @@ import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Q import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; import {Alert, Collapse} from "@mui/material"; import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; 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"; @@ -53,6 +53,27 @@ ReportSetupWidget.defaultProps = { onSaveCallback: null }; +export const buttonSX = + { + border: `1px solid ${colors.grayLines.main} !important`, + borderRadius: "0.75rem", + textTransform: "none", + fontSize: "1rem", + fontWeight: "400", + paddingLeft: "1rem", + paddingRight: "1rem", + opacity: "1", + color: colors.dark.main, + "&:hover": {color: colors.dark.main}, + "&:focus": {color: colors.dark.main}, + "&:focus:not(:hover)": {color: colors.dark.main}, + }; + +export const unborderedButtonSX = Object.assign({}, buttonSX); +unborderedButtonSX.border = "none !important"; +unborderedButtonSX.opacity = "0.7"; + + const qController = Client.getInstance(); /******************************************************************************* @@ -126,6 +147,9 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal // @ts-ignore possibly 'undefined'. const view = recordQueryRef?.current?.getCurrentView(); + + view.queryColumns.sortColumnsFixingPinPositions(); + onSaveCallback({queryFilterJson: JSON.stringify(view.queryFilter), columnsJson: JSON.stringify(view.queryColumns)}); closeEditor(); @@ -189,9 +213,12 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal { if(tableMetaData) { - if(columns?.columns?.length > 0) + for(let i = 0; i - + Add Filters +
    } { @@ -274,11 +301,11 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal { isEditable && - + Add Columns + } { - !isEditable && Your report has no filters. + !isEditable && Your report has no columns. }
    } diff --git a/src/qqq/models/fields/FieldRules.ts b/src/qqq/models/fields/FieldRules.ts new file mode 100644 index 0000000..9812bdb --- /dev/null +++ b/src/qqq/models/fields/FieldRules.ts @@ -0,0 +1,50 @@ +/* + * 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 . + */ + + +/******************************************************************************* + ** + *******************************************************************************/ +export interface FieldRule +{ + trigger: FieldRuleTrigger; + sourceField: string; + action: FieldRuleAction; + targetField: string; +} + + +/******************************************************************************* + ** + *******************************************************************************/ +export enum FieldRuleTrigger +{ + ON_CHANGE = "ON_CHANGE" +} + + +/******************************************************************************* + ** + *******************************************************************************/ +export enum FieldRuleAction +{ + CLEAR_TARGET_FIELD = "CLEAR_TARGET_FIELD" +} diff --git a/src/qqq/models/misc/PivotTableDefinitionModels.ts b/src/qqq/models/misc/PivotTableDefinitionModels.ts index 3eeffc4..655d8cc 100644 --- a/src/qqq/models/misc/PivotTableDefinitionModels.ts +++ b/src/qqq/models/misc/PivotTableDefinitionModels.ts @@ -19,6 +19,8 @@ * along with this program. If not, see . */ +import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; + /******************************************************************************* ** put a unique key value in all the pivot table group-by and value objects, @@ -30,7 +32,7 @@ export class PivotObjectKey static next(): number { - return PivotObjectKey.value++ + return PivotObjectKey.value++; } } @@ -56,7 +58,7 @@ export class PivotTableGroupBy constructor() { - this.key = PivotObjectKey.next() + this.key = PivotObjectKey.next(); } } @@ -73,43 +75,85 @@ export class PivotTableValue constructor() { - this.key = PivotObjectKey.next() + this.key = PivotObjectKey.next(); } } /******************************************************************************* - ** Functions that can be appplied to pivot table values + ** Functions that can be applied to pivot table values *******************************************************************************/ export enum PivotTableFunction { - AVERAGE = "AVERAGE", + SUM = "SUM", COUNT = "COUNT", - COUNT_NUMS = "COUNT_NUMS", + AVERAGE = "AVERAGE", MAX = "MAX", MIN = "MIN", PRODUCT = "PRODUCT", + + /////////////////////////////////////////////////////////////////////////////// + // i don't think we have a useful version of count-nums --unless we allowed // + // it on string fields, and counted if they looked like numbers? is that // + // what we should do? ... leave here as zombie in case that request comes in // + /////////////////////////////////////////////////////////////////////////////// + // COUNT_NUMS = "COUNT_NUMS", + STD_DEV = "STD_DEV", STD_DEVP = "STD_DEVP", - SUM = "SUM", VAR = "VAR", VARP = "VARP", } +const allFunctions = [ + PivotTableFunction.SUM, + PivotTableFunction.COUNT, + PivotTableFunction.AVERAGE, + PivotTableFunction.MAX, + PivotTableFunction.MIN, + PivotTableFunction.PRODUCT, + // PivotTableFunction.COUNT_NUMS, + PivotTableFunction.STD_DEV, + PivotTableFunction.STD_DEVP, + PivotTableFunction.VAR, + PivotTableFunction.VARP +]; + +const onlyCount = [PivotTableFunction.COUNT]; + +const functionsForDates = [PivotTableFunction.COUNT, PivotTableFunction.AVERAGE, PivotTableFunction.MAX, PivotTableFunction.MIN]; + +export const functionsPerFieldType: { [type: string]: PivotTableFunction[] } = {}; +functionsPerFieldType[QFieldType.STRING] = onlyCount; +functionsPerFieldType[QFieldType.BOOLEAN] = onlyCount; +functionsPerFieldType[QFieldType.BLOB] = onlyCount; +functionsPerFieldType[QFieldType.HTML] = onlyCount; +functionsPerFieldType[QFieldType.PASSWORD] = onlyCount; +functionsPerFieldType[QFieldType.TEXT] = onlyCount; +functionsPerFieldType[QFieldType.TIME] = onlyCount; + +functionsPerFieldType[QFieldType.INTEGER] = allFunctions; +functionsPerFieldType[QFieldType.DECIMAL] = allFunctions; +// functionsPerFieldType[QFieldType.LONG] = allFunctions; + +functionsPerFieldType[QFieldType.DATE] = functionsForDates; +functionsPerFieldType[QFieldType.DATE_TIME] = functionsForDates; + + ////////////////////////////////////// // labels for pivot table functions // ////////////////////////////////////// export const pivotTableFunctionLabels = { + "SUM": "Sum", + "COUNT": "Count", "AVERAGE": "Average", - "COUNT": "Count Values (COUNTA)", - "COUNT_NUMS": "Count Numbers (COUNT)", "MAX": "Max", "MIN": "Min", "PRODUCT": "Product", + // "COUNT_NUMS": "Count Numbers", "STD_DEV": "StdDev", "STD_DEVP": "StdDevp", - "SUM": "Sum", "VAR": "Var", "VARP": "Varp" }; diff --git a/src/qqq/models/query/QQueryColumns.ts b/src/qqq/models/query/QQueryColumns.ts index 210805c..8b5dccb 100644 --- a/src/qqq/models/query/QQueryColumns.ts +++ b/src/qqq/models/query/QQueryColumns.ts @@ -80,11 +80,19 @@ export default class QQueryColumns fields.forEach((field) => { const column: Column = {name: field.name, isVisible: true, width: DataGridUtils.getColumnWidthForField(field, table)}; - queryColumns.columns.push(column); if (field.name == table.primaryKeyField) { column.pinned = "left"; + + ////////////////////////////////////////////////// + // insert the primary key field after __check__ // + ////////////////////////////////////////////////// + queryColumns.columns.splice(1, 0, column); + } + else + { + queryColumns.columns.push(column); } }); @@ -392,6 +400,42 @@ export default class QQueryColumns return columnVisibilityModel; }; + + /******************************************************************************* + ** sort the columns list, so that pinned columns go to the front (left) or back + ** (right) of the list. + *******************************************************************************/ + public sortColumnsFixingPinPositions = (): void => + { + ///////////////////////////////////////////////////////////////////////////////////////////// + // do a sort to push pinned-left columns to the start, and pinned-right columns to the end // + // and otherwise, leave everything alone // + ///////////////////////////////////////////////////////////////////////////////////////////// + this.columns = this.columns.sort((a: Column, b: Column) => + { + if(a.pinned == "left" && b.pinned != "left") + { + return -1; + } + else if(b.pinned == "left" && a.pinned != "left") + { + return 1; + } + else if(a.pinned == "right" && b.pinned != "right") + { + return 1; + } + else if(b.pinned == "right" && a.pinned != "right") + { + return -1; + } + else + { + return (0); + } + }); + } + } diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index bfc2315..1c5e016 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -105,8 +105,13 @@ const qController = Client.getInstance(); ** function to produce standard version of the screen while we're "loading" ** like the main table meta data etc. *******************************************************************************/ -const getLoadingScreen = () => +const getLoadingScreen = (isModal: boolean) => { + if(isModal) + { + return ( ); + } + return (   ); @@ -2549,7 +2554,7 @@ const RecordQuery = forwardRef(({table, usage, isModal}: Props, ref) => promptForTableVariantSelection(); } - return (getLoadingScreen()); + return (getLoadingScreen(isModal)); } //////////////////////////////////////////////////////////////////////// @@ -2583,7 +2588,7 @@ const RecordQuery = forwardRef(({table, usage, isModal}: Props, ref) => setRows([]); setIsFirstRenderAfterChangingTables(true); - return (getLoadingScreen()); + return (getLoadingScreen(isModal)); } ///////////////////////////////////////////////////////////////////////////////////////////// @@ -2627,7 +2632,7 @@ const RecordQuery = forwardRef(({table, usage, isModal}: Props, ref) => if (pageState != "ready") { console.log(`page state is ${pageState}... no-op while those complete async's run...`); - return (getLoadingScreen()); + return (getLoadingScreen(isModal)); } /////////////////////////////////////////////////////////////////////////////////////////// @@ -2636,7 +2641,7 @@ const RecordQuery = forwardRef(({table, usage, isModal}: Props, ref) => /////////////////////////////////////////////////////////////////////////////////////////// if (!tableMetaData) { - return (getLoadingScreen()); + return (getLoadingScreen(isModal)); } let savedViewsComponent = null; From 04932030df02b48ff17329e666f694034a6ca29b Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Sun, 14 Apr 2024 20:11:23 -0500 Subject: [PATCH 21/22] Increase @kingsrook/qqq-frontend-core to 1.0.94 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7fa702e..8b3c519 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", - "@kingsrook/qqq-frontend-core": "1.0.92", + "@kingsrook/qqq-frontend-core": "1.0.94", "@mui/icons-material": "5.4.1", "@mui/material": "5.11.1", "@mui/styles": "5.11.1", From 48e3eeabd4661b2aea0cfab5f564747b0a510468 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 15 Apr 2024 08:47:25 -0500 Subject: [PATCH 22/22] CE-1115 fix availableFieldNames addition --- src/qqq/components/misc/FieldAutoComplete.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qqq/components/misc/FieldAutoComplete.tsx b/src/qqq/components/misc/FieldAutoComplete.tsx index 7f5e642..7effd72 100644 --- a/src/qqq/components/misc/FieldAutoComplete.tsx +++ b/src/qqq/components/misc/FieldAutoComplete.tsx @@ -73,7 +73,7 @@ function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: a continue; } - if (availableFieldNames && availableFieldNames.indexOf(fieldName) == -1) + if (availableFieldNames?.length && availableFieldNames.indexOf(fieldName) == -1) { continue; }